From cd1d285074522323cb297b8c269a2bf6b955e015 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 21:11:05 +0800 Subject: [PATCH 1/8] fix(state): admit sole-peer Next Action writes and fence shared updates Signed-off-by: huangruiteng --- .../project_lifecycle_refresh_state.py | 14 ++- .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/quota/settlement.py | 2 +- .../control_plane/todos/active_state_todos.py | 32 ++++++ .../work_items/next_action_writeback.ts | 55 +++++++++++ .../work_items/next_action_writeback_io.py | 99 +++++++++++++++++++ .../presentation/renderers/status_markdown.py | 7 ++ .../project_registry_io_manifest_v1.json | 6 +- loopx/state_projection.py | 4 +- loopx/state_refresh.py | 69 +++++++------ loopx/status.py | 6 ++ 11 files changed, 257 insertions(+), 39 deletions(-) create mode 100644 loopx/control_plane/work_items/next_action_writeback.ts create mode 100644 loopx/control_plane/work_items/next_action_writeback_io.py diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 65ea45b5bb..896c0ebc82 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -130,6 +130,14 @@ def register_refresh_state_command( "only describes the run record." ), ) + refresh_state_parser.add_argument( + "--next-action-basis", + help=( + "Next Action read basis from status attention_queue.items[].next_action_basis. " + "Required for shared multi-agent writes; optional for sole-peer writes " + "to reject an older planning snapshot." + ), + ) refresh_state_parser.add_argument( "--delivery-batch-scale", choices=DELIVERY_BATCH_SCALE_INPUT_CHOICES, @@ -330,8 +338,9 @@ def register_refresh_state_command( "--progress-scope", choices=PROGRESS_SCOPE_CHOICES, help=( - "Refresh scope. In multi-agent goals, use agent_lane for per-agent runnable " - "status, or goal with any registered peer for durable goal-level status/Next Action." + "Progress report scope; --agent-id defaults to agent_lane. A confirmed " + "sole peer may update Next Action without changing that scope. Shared " + "multi-agent prose updates require goal scope and --next-action-basis." ), ) refresh_state_parser.add_argument( @@ -507,6 +516,7 @@ def handle_refresh_state_command( classification=args.classification, recommended_action=args.recommended_action, next_action=args.next_action, + next_action_basis=getattr(args, "next_action_basis", None), delivery_batch_scale=args.delivery_batch_scale, delivery_outcome=args.delivery_outcome, delivery_boundary=getattr(args, "delivery_boundary", None), diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 4b31ce468d..43fa9eeaf8 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -235,6 +235,7 @@ import { projectTodoPlanningInventoryDetail, } from "./work_items/planning_inventory.ts"; import { resolveRefreshRecommendation } from "./work_items/refresh_recommendation.ts"; +import {resolveNextActionWriteback} from "./work_items/next_action_writeback.ts"; import { validateInteractionProjectionHookInvocation, validateInteractionProjectionHookRegistration, @@ -561,6 +562,7 @@ export function createEffectRuntimeHandlers( ["work_item.planning_inventory.project", projectTodoPlanningInventory], ["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail], ["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation], + ["work_item.next_action_writeback.resolve", resolveNextActionWriteback], ["work_item.delivery_history.project", projectDeliveryHistory], ["work_item.delivery_response.project", projectDeliveryResponse], ["work_item.delivery_claim.validate", validateDeliveryClaim], diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index 60e59a4a10..fb63112ab8 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -65,7 +65,7 @@ def _checkpoint_instructions(checkpoint: Mapping[str, Any]) -> str: "`--progress-evidence-id`, `--progress-coverage-complete`. Do not add options absent " "from the original command or change its delivery target.", "- Remove: Remove previously executed state-mutation options, even when their " - "values are unchanged: `--next-action`, `--autonomous-replan-recorded`, " + "values are unchanged: `--next-action`, `--next-action-basis`, `--autonomous-replan-recorded`, " "`--repair-delta-kind`, `--usage-json`, `--usage-codex-session`. " "Remove dependent options that become invalid without them.", "- Add: Echo `--checkpoint-read-context` from the read, and add only one valid vision decision: a valid `--agent-vision-json` packet " diff --git a/loopx/control_plane/todos/active_state_todos.py b/loopx/control_plane/todos/active_state_todos.py index a9b72c61a7..a2d966c5e0 100644 --- a/loopx/control_plane/todos/active_state_todos.py +++ b/loopx/control_plane/todos/active_state_todos.py @@ -11,6 +11,10 @@ ) from .succession_warning import public_todo_summary +from ...agent_registry import registered_agent_ids_for_goal +from ..work_items.next_action_writeback_io import ( + load_next_action_source_goal, next_action_writeback_context, project_agent_next_actions, +) def _redacted_status_todo_fields(fields: dict[str, Any]) -> dict[str, Any]: redacted = dict(fields) @@ -50,7 +54,9 @@ def active_state_todo_fields( goal: dict[str, Any], *, runtime_root: Path | None = None, + registry_path: Path | None = None, todo_snapshot: CanonicalTodoSnapshot | None = None, + include_agent_next_actions: bool = False, rollout_events: Sequence[Mapping[str, Any]] | None = None, resolve_goal_local_path: Callable[..., Path | None], active_state_next_action_entries: Callable[..., list[str]], @@ -67,6 +73,19 @@ def active_state_todo_fields( ) -> dict[str, Any]: todo_field_redactor = redacted_status_todo_fields or _redacted_status_todo_fields goal_id = str(goal.get("id") or "").strip() + source_registry = None + admission_goal: dict[str, Any] | None = goal + if registry_path is not None and goal_id: + try: + source_registry, source_goal = load_next_action_source_goal(registry_path, goal_id) + except (OSError, ValueError): + # Status remains a read model when its source is unavailable. It + # must not mint a write basis from a stale shared roster. + admission_goal = None + else: + admission_goal = source_goal + if source_goal is not None: + goal = {**goal, **source_goal} # Inspect authority before the display file. A missing/stale projection is # not an empty Todo collection, and an unavailable provider must fail closed. canonical_reader = todo_snapshot.read if todo_snapshot is not None else read_canonical_todos_if_promoted @@ -80,8 +99,10 @@ def active_state_todo_fields( require_no_legacy_todo_events(goal, state_path=state_path) if canonical is None and (state_path is None or not state_path.exists()): return {} + state_readable = False try: state_text = state_path.read_text(encoding="utf-8") if state_path is not None else "" + state_readable = state_path is not None except OSError: if canonical is None: return {} @@ -130,6 +151,17 @@ def active_state_todo_fields( if next_action_entries: fields["active_state_next_action"] = next_action_entries[0] fields["active_state_next_action_entries"] = next_action_entries + if goal_id and state_readable and admission_goal is not None: + fields["next_action_basis"] = next_action_writeback_context(admission_goal, state_text, source_registry=source_registry)["basis"] + # This is a read projection, not a rewrite of shared compatibility prose. + # Select from the complete task source, never a bounded status page. + if include_agent_next_actions and len(registered_agent_ids_for_goal(admission_goal)) > 1: + route_fields = fields if canonical is not None else parse_active_state_todos( + state_text, goal=goal, state_path=state_path, rollout_events=events, item_limit=None, + ) + routes = project_agent_next_actions(goal, route_fields) + if routes: + fields["agent_next_actions"] = routes warning = backlog_hygiene_warning( state_text, agent_todos=fields.get("agent_todos") if isinstance(fields.get("agent_todos"), dict) else None, diff --git a/loopx/control_plane/work_items/next_action_writeback.ts b/loopx/control_plane/work_items/next_action_writeback.ts new file mode 100644 index 0000000000..cd52408a43 --- /dev/null +++ b/loopx/control_plane/work_items/next_action_writeback.ts @@ -0,0 +1,55 @@ +/** Admission for compatibility prose only, never Todo or Goal amendment authority. */ +import {createHash} from "node:crypto"; +import {ENVELOPED_SHA256_PATTERN} from "../content_digest.ts"; +import {type JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {optionalNonEmptyString, requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; + +export function resolveNextActionWriteback(value: unknown): JsonObject { + const request = requireJsonObject(value, "next_action_writeback"); + const goalId = requireNonEmptyString(request.goal_id, "goal_id"); + const revision = requireNonEmptyString(request.state_revision, "state_revision"); + const goalRevision = requireNonEmptyString(request.goal_revision, "goal_revision"); + if (!ENVELOPED_SHA256_PATTERN.test(revision) || !ENVELOPED_SHA256_PATTERN.test(goalRevision)) { + throw new EffectRuntimeRequestError("state_revision and goal_revision must be SHA-256 revisions"); + } + // The adapter supplies the complete normalized roster, including offline peers. + const agents = [...new Set(requireStringArray(request.registered_agents, "registered_agents"))].sort(); + const entries = requireStringArray(request.next_action_entries, "next_action_entries"); + const basis = "sha256:" + createHash("sha256").update(JSON.stringify([ + goalId, goalRevision, agents, revision, + ])).digest("hex"); + const context: JsonObject = {basis, registered_agents: agents, next_action_entries: entries}; + if (request.write === undefined || request.write === null) return context; + const write = requireJsonObject(request.write, "write"); + const actor = optionalNonEmptyString(write.agent_id, "agent_id"); + const scope = requireNonEmptyString(write.progress_scope, "progress_scope"); + const expected = optionalNonEmptyString(write.expected_basis, "expected_basis"); + const sourceBasis = optionalNonEmptyString(write.source_basis, "source_basis"); + if (scope !== "goal" && scope !== "agent_lane") { + throw new EffectRuntimeRequestError("progress_scope must be goal or agent_lane"); + } + if (expected && !ENVELOPED_SHA256_PATTERN.test(expected)) { + throw new EffectRuntimeRequestError("--next-action-basis must be a SHA-256 basis"); + } + const reject = (code: string, error: string): JsonObject => ({ + ...context, admitted: false, error_code: code, error, reread_required: true, + }); + if (actor && agents.length && !agents.includes(actor)) { + return reject("next_action_actor_unregistered", "Next Action writer is not registered for this Goal"); + } + if ((expected && expected !== basis) || (sourceBasis && sourceBasis !== basis)) { + return reject("next_action_basis_conflict", "Next Action read basis changed; read current status and rejudge before retrying"); + } + if (agents.length > 1 && !actor) { + return reject("next_action_actor_required", "multi-agent Next Action write requires --agent-id"); + } + if (scope === "agent_lane" && !(actor && agents.length === 1 && agents[0] === actor)) { + return reject("next_action_shared_scope_required", + "agent-lane refresh-state cannot update shared Next Action without a confirmed sole registered peer; update your Todo route, or use --progress-scope goal with --next-action-basis"); + } + if (agents.length > 1 && !expected) { + return reject("next_action_basis_required", "shared Next Action write requires --next-action-basis from current status"); + } + return {...context, admitted: true}; +} diff --git a/loopx/control_plane/work_items/next_action_writeback_io.py b/loopx/control_plane/work_items/next_action_writeback_io.py new file mode 100644 index 0000000000..9dab11ffd9 --- /dev/null +++ b/loopx/control_plane/work_items/next_action_writeback_io.py @@ -0,0 +1,99 @@ +"""Source I/O for TS-owned Next Action admission; no task/intent write authority.""" +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import ExitStack, contextmanager +from pathlib import Path +from typing import Any + +from ...agent_registry import load_goal_from_registry, registered_agent_ids_for_goal +from ...file_lock import exclusive_cross_runtime_file_lock +from ...state_projection import active_state_next_action_entries +from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result +from ..runtime.local_state_write_correctness import active_state_revision, stable_write_digest +from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route + + +class NextActionWritebackRejected(ValueError): + def __init__(self, result: dict[str, Any]) -> None: + super().__init__(result["error"]) + self.code = result["error_code"] + self.payload = {"next_action_writeback": result} + + +def next_action_writeback_context( + goal: dict[str, Any] | None, state_text: str, *, goal_id: str | None = None, + source_registry: Path | None = None, + write: dict[str, Any] | None = None, +) -> dict[str, Any]: + source = goal if goal is not None else {"id": goal_id} + request = { + "goal_id": source["id"], + # Bind registry identity/lifecycle/routing facts, not status's derived + # run history, projections or display defaults. + "goal_revision": "sha256:" + stable_write_digest({ + "registered_goal_present": goal is not None, + "source_registry": str(source_registry.resolve()) if source_registry is not None else None, + "facts": {key: source.get(key) for key in ("id", "goal_instance_id", "status", "repo", "state_file")}, + }), + "registered_agents": registered_agent_ids_for_goal(goal), + "state_revision": active_state_revision(state_text)["value"], + # A bounded readback, but the revision above covers the complete bytes. + "next_action_entries": active_state_next_action_entries(state_text, limit=3, text_limit=500), + "write": write, + } + try: + result = effect_runtime_result("work_item.next_action_writeback.resolve", request) + except EffectRuntimeRejected as error: + raise ValueError(str(error)) from error + if not isinstance(result, dict) or not isinstance(result.get("basis"), str): + raise RuntimeError("invalid TypeScript Next Action admission result") + if write is not None and result.get("admitted") is not True: + raise NextActionWritebackRejected(result) + return result + + +def load_next_action_source_goal(registry_path: Path, goal_id: str) -> tuple[Path, dict[str, Any] | None]: + # Runtime overrides choose execution/projection, never roster authority. + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + source_registry = Path(route["source_registry"]).resolve() + return source_registry, load_goal_from_registry(source_registry, goal_id) + + +def project_agent_next_actions(goal: dict[str, Any], todo_fields: dict[str, Any]) -> list[dict[str, Any]]: + """Compose independent existing lane selectors; never infer a new owner.""" + from ..agents.agent_lane_recommendation import build_agent_lane_next_action + + agents = registered_agent_ids_for_goal(goal) + if len(agents) < 2: + return [] + routes = [] + for agent in agents: + route = build_agent_lane_next_action( + agent_identity={"agent_id": agent}, agent_todo_summary=todo_fields.get("agent_todos"), + capability_gate=None, active_next_action=[], + ) + if route: + routes.append({key: route[key] for key in ("agent_id", "todo_id", "text") if key in route}) + return routes + + +@contextmanager +def next_action_source_guard( + registry_path: Path, state_path: Path, goal_id: str, + *, source_registry: Path, +) -> Iterator[tuple[dict[str, Any] | None, str]]: + # Registration/configuration use the same registry lock. Quota's outer + # index/source guard precedes this short registry -> state critical section. + # Release before projection/global sync to avoid recursive registry locking. + # Configuration locks source before its shared projection. Keep that order, + # and revalidate the projection's route under both locks before using it. + with ExitStack() as stack: + stack.enter_context(exclusive_cross_runtime_file_lock(source_registry, operation="next-action-writeback")) + if registry_path.resolve() != source_registry.resolve(): + stack.enter_context(exclusive_cross_runtime_file_lock(registry_path, operation="next-action-writeback")) + current_source, goal = load_next_action_source_goal(registry_path, goal_id) + if current_source != source_registry.resolve(): + raise ValueError("Next Action source registry changed; read current status and rejudge before retrying") + stack.enter_context(exclusive_cross_runtime_file_lock(state_path, operation="next-action-writeback")) + yield goal, state_path.read_text(encoding="utf-8") diff --git a/loopx/presentation/renderers/status_markdown.py b/loopx/presentation/renderers/status_markdown.py index 7ab021b9a5..89eb03715d 100644 --- a/loopx/presentation/renderers/status_markdown.py +++ b/loopx/presentation/renderers/status_markdown.py @@ -1084,6 +1084,13 @@ def append_attention_queue_item_header_markdown( active_state_action = markdown_scalar(item.get("active_state_next_action") or "") if active_state_action: lines.append(f" - active_state_next_action: {active_state_action}") + routes = item.get("agent_next_actions") + for route in routes if isinstance(routes, list) else []: + if isinstance(route, dict): + lines.append( + f" - agent_next_action: {markdown_scalar(route.get('agent_id') or '')} " + f"{markdown_scalar(route.get('todo_id') or '')} {markdown_scalar(route.get('text') or '')}" + ) latest_run_action = markdown_scalar(item.get("latest_run_recommended_action") or "") if latest_run_action: lines.append(f" - latest_run_recommended_action: {latest_run_action}") diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 23634324b1..ac20ed044b 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -743,7 +743,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#1", - "line": 596, + "line": 606, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -751,7 +751,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#2", - "line": 677, + "line": 687, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -2095,7 +2095,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 901, + "line": 894, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/state_projection.py b/loopx/state_projection.py index 095d49d3f2..6411508dfb 100644 --- a/loopx/state_projection.py +++ b/loopx/state_projection.py @@ -196,8 +196,8 @@ def next_action_projection_warning( "explicitly preserving the active-state Next Action" ) warning["recommended_action"] = ( - "run the agent-lane action without mutating active-state Next Action; " - "only the primary/goal route should write a new durable Next Action" + "continue the agent's Todo route; update shared Next Action through " + "refresh-state with confirmed sole-peer admission or goal scope and a current read basis" ) else: warning["reason"] = ( diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 924e4cba3f..65c7177fdb 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -76,12 +76,16 @@ build_shared_runtime_projection, write_shared_runtime_projection, ) +from .agent_registry import registered_agent_ids_for_goal +from .control_plane.work_items.next_action_writeback_io import ( + load_next_action_source_goal, next_action_source_guard, next_action_writeback_context, +) from .control_plane.runtime.runtime_projection_route import ( compact_runtime_projection_route, resolve_runtime_projection_route, ) from .feedback import validate_local_control_text, validate_public_safe_text -from .file_lock import exclusive_file_lock, exclusive_cross_runtime_file_lock +from .file_lock import exclusive_file_lock from .control_plane.coordination.runtime_shadow_writer_adapter import require_prose_state_write_allowed from .control_plane.todos.active_state_editing import atomic_write_state_text from .global_registry import sync_project_registry_to_global @@ -108,7 +112,6 @@ state_projection_gap_warning, ) from .control_plane.todos.contract import ( - normalize_todo_claimed_by, normalize_todo_replan_obligation_id, ) from .control_plane.todos.completion_validation_accountability import ( @@ -194,20 +197,7 @@ def normalize_next_action_text(value: str) -> str: def registered_agents_for_goal(registry_goal: dict[str, Any] | None) -> list[str]: - coordination = ( - registry_goal.get("coordination") - if registry_goal and isinstance(registry_goal.get("coordination"), dict) - else {} - ) - registered_raw = coordination.get("registered_agents") if isinstance(coordination, dict) else [] - registered_values = registered_raw if isinstance(registered_raw, list) else [] - registered_agents: list[str] = [] - for value in registered_values: - candidate = value.get("id") if isinstance(value, dict) else value - normalized = normalize_todo_claimed_by(candidate) - if normalized: - registered_agents.append(normalized) - return registered_agents + return registered_agent_ids_for_goal(registry_goal) def normalize_progress_scope(value: str | None) -> str: @@ -801,6 +791,7 @@ def refresh_state_run( classification: str, recommended_action: str | None, next_action: str | None = None, + next_action_basis: str | None = None, delivery_batch_scale: str | None = None, delivery_outcome: str | None = None, delivery_boundary: str | None = None, @@ -830,6 +821,8 @@ def refresh_state_run( from .control_plane.todos.provider_projection import recover_refresh_todo_projection safe_goal_id = validate_goal_id_path_segment(goal_id) + if next_action_basis and not next_action: + raise ValueError("--next-action-basis requires --next-action") if checkpoint_read_context_id and not turn_instance_id: raise ValueError("--checkpoint-read-context requires the original Turn identity") validate_public_safe_text("classification", classification) @@ -946,6 +939,7 @@ def refresh_state_run( "workspace_requested": delivery_workspace_path is not None, "mutation": { "next_action": next_action, + **({"next_action_basis": next_action_basis} if next_action_basis else {}), "autonomous_replan_recorded": autonomous_replan_recorded, "repair_delta_kinds": repair_delta_kinds, "usage_measurement": usage_measurement, @@ -1009,8 +1003,13 @@ def refresh_state_run( if sync_global and route_status in {"resolved", "single_runtime"} else None ) + next_action_source_registry = registry_path.resolve() + state_registry = registry + if next_action: + next_action_source_registry, source_goal = load_next_action_source_goal(registry_path, safe_goal_id) + state_registry = {**registry, "goals": [source_goal] if source_goal is not None else []} registry_goal, resolved_project, resolved_state_file = resolve_goal_state( - registry=registry, + registry=state_registry, goal_id=safe_goal_id, project_override=project, state_file_override=state_file, @@ -1023,6 +1022,8 @@ def refresh_state_run( ) expected_write_state_text = state_text normalized_next_action = normalize_next_action_text(next_action) if next_action else None + if normalized_next_action and registry_goal is None: + raise ValueError("--next-action requires a registry Goal; register its state route first") registered_agents = registered_agents_for_goal(registry_goal) known_agents = {agent for agent in registered_agents if agent} multi_agent_goal = len(known_agents) > 1 @@ -1053,11 +1054,6 @@ def refresh_state_run( if normalized_progress_scope == AGENT_LANE_PROGRESS_SCOPE: if not normalized_agent_id: raise ValueError("--progress-scope agent_lane requires --agent-id") - if normalized_next_action: - raise ValueError( - "agent-lane refresh-state cannot update the durable active-state Next Action; " - "rerun without --next-action or use --progress-scope goal from a registered peer" - ) if normalized_progress_scope == GOAL_PROGRESS_SCOPE: if normalized_agent_lane: raise ValueError("--agent-lane requires --progress-scope agent_lane") @@ -1099,8 +1095,14 @@ def refresh_state_run( generated_at = now_local() active_state_next_action_update: dict[str, Any] | None = None if normalized_next_action: - with exclusive_cross_runtime_file_lock(resolved_state_file): - locked_state_text = resolved_state_file.read_text(encoding="utf-8") + source_basis = next_action_writeback_context(registry_goal, state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry)["basis"] + with next_action_source_guard(registry_path, resolved_state_file, safe_goal_id, source_registry=next_action_source_registry) as (current_goal, locked_state_text): + admission = next_action_writeback_context(current_goal, locked_state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry, write={ + "agent_id": normalized_agent_id or None, + "progress_scope": normalized_progress_scope, + "expected_basis": next_action_basis, + "source_basis": source_basis, + }) expected_write_state_text = locked_state_text updated_state_text, state_updated = replace_next_action_section( locked_state_text, @@ -1115,6 +1117,8 @@ def refresh_state_run( "would_update": bool(state_updated), "dry_run": bool(dry_run), "updated_at": generated_at if state_updated else None, + "read_basis": admission["basis"], + "agent_id": normalized_agent_id or None, } state_text = updated_state_text if state_updated else locked_state_text @@ -1315,19 +1319,22 @@ def refresh_state_run( and active_state_next_action_update.get("would_update") and not dry_run ): - with exclusive_cross_runtime_file_lock(resolved_state_file): - current_state_text = resolved_state_file.read_text(encoding="utf-8") - if current_state_text != expected_write_state_text: - raise ValueError( - "active goal state changed while refresh-state was qualifying " - "its semantic writeback; retry from the current state" - ) + with next_action_source_guard(registry_path, resolved_state_file, safe_goal_id, source_registry=next_action_source_registry) as (current_goal, current_state_text): + # Recheck complete membership and the snapshot captured before + # semantic qualification, even for a sole actor with no flag. + next_action_writeback_context(current_goal, current_state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry, write={ + "agent_id": normalized_agent_id or None, + "progress_scope": normalized_progress_scope, + "expected_basis": active_state_next_action_update["read_basis"], + }) require_prose_state_write_allowed( registry_path=registry_path, runtime_root=runtime_root, goal_id=safe_goal_id, state_path=resolved_state_file, original_text=current_state_text, planned_text=state_text, ) + applied_basis = next_action_writeback_context(current_goal, state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry)["basis"] atomic_write_state_text(resolved_state_file, state_text) + active_state_next_action_update["applied_basis"] = applied_basis record = build_state_refresh_record( goal_id=safe_goal_id, state_file=resolved_state_file, diff --git a/loopx/status.py b/loopx/status.py index ea6912b0d4..5629e81d1b 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -711,12 +711,16 @@ def active_state_todo_fields( goal: dict[str, Any], *, runtime_root: Path | None = None, + registry_path: Path | None = None, todo_snapshot: _CanonicalTodoSnapshot | None = None, + include_agent_next_actions: bool = False, rollout_events: Sequence[Mapping[str, Any]] | None = None, ) -> dict[str, Any]: return _active_state_todo_fields_read_model( goal, runtime_root=runtime_root, + registry_path=registry_path, + include_agent_next_actions=include_agent_next_actions, rollout_events=rollout_events, **({"todo_snapshot": todo_snapshot} if todo_snapshot is not None else {}), resolve_goal_local_path=resolve_goal_local_path, @@ -1110,6 +1114,8 @@ def request_active_state_todo_fields( return active_state_todo_fields( goal, runtime_root=runtime_root, + include_agent_next_actions=include_task_graph, + registry_path=registry_path, rollout_events=supplied_events, **({"todo_snapshot": todo_snapshot} if todo_snapshot is not None else {}), ) From 370c8f9007eca14128bed6bf8127616222faac78 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 21:12:26 +0800 Subject: [PATCH 2/8] test(state): cover Next Action scope, source authority and stale writers Signed-off-by: huangruiteng --- .../refresh-state-agent-lane-scope-smoke.py | 10 +- .../next-action-projection-contract-smoke.py | 3 +- .../test_next_action_writeback.py | 284 ++++++++++++++++++ .../test_refresh_checkpoint_recovery.py | 2 +- .../next_action_writeback.test.ts | 48 +++ 5 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 tests/control_plane/test_next_action_writeback.py create mode 100644 tests/control_plane_ts/next_action_writeback.test.ts diff --git a/examples/control_plane/refresh-state-agent-lane-scope-smoke.py b/examples/control_plane/refresh-state-agent-lane-scope-smoke.py index a22fbe9709..02d6458782 100644 --- a/examples/control_plane/refresh-state-agent-lane-scope-smoke.py +++ b/examples/control_plane/refresh-state-agent-lane-scope-smoke.py @@ -14,6 +14,8 @@ import loopx.state_refresh as state_refresh from loopx.history import collect_history from loopx.status import collect_status +from loopx.agent_registry import load_goal_from_registry +from loopx.control_plane.work_items.next_action_writeback_io import next_action_writeback_context GOAL_ID = "refresh-state-agent-lane-goal" @@ -222,7 +224,7 @@ def main() -> None: ) expect_value_error( - "agent-lane refresh-state cannot update the durable active-state Next Action", + "agent-lane refresh-state cannot update shared Next Action", lambda: state_refresh.refresh_state_run( registry_path=registry_path, runtime_root_override=str(runtime), @@ -380,7 +382,7 @@ def main() -> None: ) expect_value_error( - "agent-lane refresh-state cannot update the durable active-state Next Action", + "agent-lane refresh-state cannot update shared Next Action", lambda: state_refresh.refresh_state_run( registry_path=registry_path, runtime_root_override=str(runtime), @@ -409,6 +411,10 @@ def main() -> None: recommended_action=PRIMARY_AGENT_LANE_ACTION, next_action=PRIMARY_AGENT_LANE_ACTION, delivery_batch_scale="single_surface", + next_action_basis=next_action_writeback_context( + load_goal_from_registry(registry_path, GOAL_ID), state_path.read_text(encoding="utf-8"), + source_registry=registry_path, + )["basis"], delivery_outcome="outcome_progress", agent_id="codex-main-control", progress_scope="goal", diff --git a/examples/project/next-action-projection-contract-smoke.py b/examples/project/next-action-projection-contract-smoke.py index 6decaacd65..b3468415e1 100644 --- a/examples/project/next-action-projection-contract-smoke.py +++ b/examples/project/next-action-projection-contract-smoke.py @@ -297,6 +297,7 @@ def main() -> None: classification="state_refreshed", recommended_action=UPDATED_RUN_RECOMMENDATION, next_action=UPDATED_NEXT_ACTION, + next_action_basis=first_item["next_action_basis"], agent_id="codex-main-control", progress_scope="goal", dry_run=False, @@ -340,7 +341,7 @@ def main() -> None: ), second_decision assert ( warning["recommended_action"] - == "run the agent-lane action without mutating active-state Next Action; only the primary/goal route should write a new durable Next Action" + == "continue the agent's Todo route; update shared Next Action through refresh-state with confirmed sole-peer admission or goal scope and a current read basis" ), second_decision assert ( warning["agent_lane_next_action"] == lane["text"] diff --git a/tests/control_plane/test_next_action_writeback.py b/tests/control_plane/test_next_action_writeback.py new file mode 100644 index 0000000000..1516370916 --- /dev/null +++ b/tests/control_plane/test_next_action_writeback.py @@ -0,0 +1,284 @@ +"""Real refresh/status transactions on disposable Goal state.""" +from concurrent.futures import ThreadPoolExecutor +import json +import subprocess +import sys + +import pytest + +import loopx.state_refresh as refresh +from loopx.control_plane.work_items.next_action_writeback_io import ( + NextActionWritebackRejected, next_action_writeback_context, +) +from loopx.status import collect_status +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from tests.control_plane import test_todo_projection_concurrency as projection_fixtures + +canonical_projection = projection_fixtures.canonical_projection + +STATE = """# Active Goal State + +## Agent Todo + +- [ ] [P1] Inspect the parser. + +- [ ] [P1] Evaluate the current artifact. + + +## Next Action + +- Preserve the current shared route. +""" +VISION = {"state": "vision_patch_proposed", "vision_patch": { + "vision_summary": "Inspect current evidence before the next experiment.", + "acceptance_summary": "Validated evidence and scoped next work remain required.", +}} + + +def fixture(tmp_path, agents=("agent-a",)): + state = tmp_path / "state.md" + state.write_text(STATE) + registry = tmp_path / "registry.json" + goal = {"id": "next-action-goal", "status": "active", "repo": str(tmp_path), + "state_file": state.name, "coordination": {"registered_agents": list(agents)}} + runtime = tmp_path / "runtime" + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [goal]})) + return registry, state, runtime, goal + + +def write(registry, runtime, **kwargs): + options = dict( + registry_path=registry, runtime_root_override=str(runtime), goal_id="next-action-goal", + project=None, state_file=None, classification="state_refreshed", recommended_action=None, + agent_id="agent-a", next_action="Evaluate the new artifact; preserve the incumbent.", + agent_vision_packet=VISION, dry_run=False, sync_global=False, + ) + options.update(kwargs) + return refresh.refresh_state_run(**options) + + +def test_sole_peer_write_keeps_attribution_scope_and_task_owners(tmp_path): + registry, state, runtime, _ = fixture(tmp_path) + payload = write(registry, runtime) + assert payload["progress_scope"] == "agent_lane" + assert payload["agent_id"] == "agent-a" + assert payload["vision_checkpoint"]["satisfied"] is True + assert payload["active_state_next_action_update"]["agent_id"] == "agent-a" + assert "Evaluate the new artifact; preserve the incumbent." in state.read_text() + assert state.read_text().split("## Next Action")[0] == STATE.split("## Next Action")[0] + run = json.loads((runtime / "goals/next-action-goal/runs/index.jsonl").read_text()) + assert run["progress_scope"] == "agent_lane" + assert run["agent_id"] == "agent-a" + + +@pytest.mark.parametrize("agents,scope,basis,code", [ + ([], None, None, "next_action_shared_scope_required"), + (["agent-b"], None, None, None), + (["agent-a", "offline-peer"], None, None, "next_action_shared_scope_required"), + (["agent-a", "agent-b"], "goal", None, "next_action_basis_required"), + (["agent-a"], None, "sha256:" + "0" * 64, "next_action_basis_conflict"), +]) +def test_rejections_do_not_write_state_or_append_runs(tmp_path, agents, scope, basis, code): + registry, state, runtime, _ = fixture(tmp_path, agents) + with pytest.raises(ValueError) as caught: + write(registry, runtime, progress_scope=scope, next_action_basis=basis) + if code: + assert caught.value.code == code + assert state.read_text() == STATE + assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() + + +def test_shared_write_uses_status_basis_and_preserves_both_routes(tmp_path): + registry, state, runtime, _ = fixture(tmp_path, ("agent-a", "agent-b")) + status = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) + item = next(item for item in status["attention_queue"]["items"] if item["goal_id"] == "next-action-goal") + assert {r["agent_id"] for r in item["agent_next_actions"]} == {"agent-a", "agent-b"} + basis = item["next_action_basis"] + result = write(registry, runtime, progress_scope="goal", next_action_basis=basis) + assert result["active_state_next_action_update"]["read_basis"] == basis + assert result["active_state_next_action_update"]["applied_basis"] != basis + after = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) + item_after = next(item for item in after["attention_queue"]["items"] if item["goal_id"] == "next-action-goal") + assert [(r["agent_id"], r["todo_id"]) for r in item_after["agent_next_actions"]] == [("agent-a", "todo_parser"), ("agent-b", "todo_evaluate")] + assert state.read_text().split("## Next Action")[0] == STATE.split("## Next Action")[0] + + +@pytest.mark.parametrize("change_membership", [False, True]) +def test_final_commit_rechecks_state_and_membership(tmp_path, monkeypatch, change_membership): + registry, state, runtime, _ = fixture(tmp_path) + original = refresh.qualify_refresh_replan_writeback + def concurrent_change(**kwargs): + result = original(**kwargs) + if change_membership: + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("offline-peer") + registry.write_text(json.dumps(data)) + else: + state.write_text(STATE.replace("Preserve the current shared route.", "A newer session chose this route.")) + return result + monkeypatch.setattr(refresh, "qualify_refresh_replan_writeback", concurrent_change) + with pytest.raises(NextActionWritebackRejected) as caught: + write(registry, runtime) + assert caught.value.code == "next_action_basis_conflict" + assert caught.value.payload["next_action_writeback"]["next_action_entries"] + assert "Evaluate the new artifact; preserve the incumbent." not in state.read_text() + assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() + + +def test_same_actor_concurrent_cli_writers_cannot_commit_the_same_old_basis(tmp_path): + registry, state, runtime, goal = fixture(tmp_path) + basis = next_action_writeback_context(goal, STATE, source_registry=registry)["basis"] + vision_path = tmp_path / "vision.json" + vision_path.write_text(json.dumps(VISION)) + def run(action): + command = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry), + "--runtime-root", str(runtime), "refresh-state", "--goal-id", goal["id"], + "--agent-id", "agent-a", "--next-action", action, "--next-action-basis", basis, + "--agent-vision-json", str(vision_path), "--no-global-sync"] + result = subprocess.run(command, text=True, capture_output=True, timeout=30) + assert result.stdout, result.stderr + return result.returncode, json.loads(result.stdout) + with ThreadPoolExecutor(max_workers=2) as workers: + results = list(workers.map(run, ["Inspect new parser evidence.", "Evaluate the new artifact."])) + assert sorted(code for code, _ in results) == [0, 1], results + failure = next(payload for code, payload in results if code) + assert failure["error_code"] == "next_action_basis_conflict" + assert len((runtime / "goals/next-action-goal/runs/index.jsonl").read_text().splitlines()) == 1 + + +def test_basis_covers_untruncated_state_and_legacy_roster_sources(tmp_path): + _, _, _, goal = fixture(tmp_path) + first = next_action_writeback_context(goal, STATE + "\n" + "x" * 500) + second = next_action_writeback_context(goal, STATE + "\n" + "x" * 501) + assert first["basis"] != second["basis"] + goal["spawn_policy"] = {"registered_agents": ["offline-peer"]} + assert next_action_writeback_context(goal, STATE)["registered_agents"] == ["agent-a", "offline-peer"] + + +def test_dry_run_checks_admission_without_writing_or_appending(tmp_path): + registry, state, runtime, goal = fixture(tmp_path) + result = write(registry, runtime, dry_run=True) + assert result["active_state_next_action_update"]["would_update"] is True + assert result["active_state_next_action_update"]["updated"] is False + assert state.read_text() == STATE + assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() + goal["coordination"]["registered_agents"].append("offline-peer") + registry.write_text(json.dumps({"goals": [goal]})) + with pytest.raises(NextActionWritebackRejected): + write(registry, runtime, dry_run=True) + + +def test_single_peer_permission_does_not_bypass_vision_continuity_rules(tmp_path): + registry, state, runtime, _ = fixture(tmp_path) + with pytest.raises(ValueError, match="in_flight_continuation"): + write(registry, runtime, delivery_boundary="in_flight_continuation", delivery_outcome="outcome_progress") + assert state.read_text() == STATE + assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() + + +@pytest.mark.parametrize("field,value", [ + ("goal_instance_id", "new-instance"), ("status", "paused"), ("state_file", "other-state.md"), +]) +def test_basis_fences_goal_identity_lifecycle_and_route_changes(tmp_path, field, value): + registry, state, runtime, goal = fixture(tmp_path) + basis = next_action_writeback_context(goal, STATE)["basis"] + goal[field] = value + assert next_action_writeback_context(goal, STATE)["basis"] != basis + # A bounded status projection must not introduce a new revision. + enriched = {**goal, "latest_runs": [{"classification": "state_refreshed"}], "quota": {"remaining": 1}} + assert next_action_writeback_context(enriched, STATE)["basis"] == next_action_writeback_context(goal, STATE)["basis"] + + +def test_missing_goal_fails_at_the_next_action_boundary(tmp_path): + registry, state, runtime, _ = fixture(tmp_path) + registry.write_text(json.dumps({"goals": []})) + with pytest.raises(ValueError, match="requires a registry Goal"): + write(registry, runtime, project=tmp_path, state_file=state) + assert state.read_text() == STATE + + +def shared_fixture(tmp_path): + registry, state, runtime, goal = fixture(tmp_path, ("agent-a", "agent-b")) + mirror = tmp_path / "shared-registry.json" + stale = {**goal, "source_registry": str(registry), "state_file": "stale-state.md", + "coordination": {"registered_agents": ["agent-a"]}} + (tmp_path / "stale-state.md").write_text(STATE.replace("current shared route", "stale mirror route")) + mirror.write_text(json.dumps({"registry_role": "global-local", "common_runtime_root": str(runtime), "goals": [stale]})) + return registry, mirror, state, runtime, goal + + +def test_stale_shared_roster_cannot_grant_sole_peer_authority(tmp_path): + registry, mirror, state, runtime, goal = shared_fixture(tmp_path) + with pytest.raises(NextActionWritebackRejected) as caught: + write(mirror, runtime) + assert caught.value.code == "next_action_shared_scope_required" + assert state.read_text() == STATE + status = collect_status(registry_path=mirror, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) + item = next(item for item in status["attention_queue"]["items"] if item["goal_id"] == goal["id"]) + basis = next_action_writeback_context(goal, STATE, source_registry=registry)["basis"] + assert item["next_action_basis"] == basis + assert {route["agent_id"] for route in item["agent_next_actions"]} == {"agent-a", "agent-b"} + result = write(mirror, runtime, progress_scope="goal", next_action_basis=basis) + assert result["agent_id"] == "agent-a" + assert result["active_state_next_action_update"]["read_basis"] == basis + assert "Evaluate the new artifact" in state.read_text() + assert "stale mirror route" in (tmp_path / "stale-state.md").read_text() + + +def test_missing_shared_source_never_mints_a_basis_or_allows_write(tmp_path): + registry, mirror, state, runtime, _ = shared_fixture(tmp_path) + registry.unlink() + with pytest.raises(ValueError, match="source_registry is missing"): + write(mirror, runtime) + assert state.read_text() == STATE + status = collect_status(registry_path=mirror, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) + assert all("next_action_basis" not in item for item in status["attention_queue"]["items"]) + + +@pytest.mark.parametrize("change_route", [False, True]) +def test_shared_source_is_rechecked_before_commit(tmp_path, monkeypatch, change_route): + registry, mirror, state, runtime, goal = shared_fixture(tmp_path) + basis = next_action_writeback_context(goal, STATE, source_registry=registry)["basis"] + original = refresh.qualify_refresh_replan_writeback + def concurrent_change(**kwargs): + result = original(**kwargs) + if change_route: + successor = tmp_path / "successor-registry.json" + successor.write_text(registry.read_text()) + data = json.loads(mirror.read_text()) + data["goals"][0]["source_registry"] = str(successor) + mirror.write_text(json.dumps(data)) + else: + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("offline-peer") + registry.write_text(json.dumps(data)) + return result + monkeypatch.setattr(refresh, "qualify_refresh_replan_writeback", concurrent_change) + with pytest.raises(ValueError, match="source registry changed|read basis changed"): + write(mirror, runtime, progress_scope="goal", next_action_basis=basis) + assert state.read_text() == STATE + assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() + + +def test_sole_peer_prose_write_preserves_real_canonical_authority(canonical_projection): + args, state, _, _ = canonical_projection + registry = args["registry_path"] + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"] = {"registered_agents": ["agent-a"]} + registry.write_text(json.dumps(data)) + before = read_canonical_todos_if_promoted(runtime_root=args["runtime_root"], goal_id=args["goal_id"]) + result = refresh.refresh_state_run( + registry_path=registry, runtime_root_override=str(args["runtime_root"]), goal_id=args["goal_id"], + project=None, state_file=None, classification="state_refreshed", recommended_action=None, + agent_id="agent-a", agent_vision_packet=VISION, next_action="Validate the canonical work.", + dry_run=False, sync_global=False, + ) + assert result["progress_scope"] == "agent_lane" + assert result["projection_delivery"] == "delivered" + assert "Validate the canonical work." in state.read_text() + assert "Canonical work" in state.read_text() + assert "Human narrative." in state.read_text() + after = read_canonical_todos_if_promoted(runtime_root=args["runtime_root"], goal_id=args["goal_id"]) + assert (after["provider_revision"], after["cursor"], after["todos"]) == ( + before["provider_revision"], before["cursor"], before["todos"], + ) diff --git a/tests/control_plane/test_refresh_checkpoint_recovery.py b/tests/control_plane/test_refresh_checkpoint_recovery.py index fc344dafda..24124d03e4 100644 --- a/tests/control_plane/test_refresh_checkpoint_recovery.py +++ b/tests/control_plane/test_refresh_checkpoint_recovery.py @@ -32,7 +32,7 @@ def _assert_checkpoint_instructions(rendered: str) -> None: assert "same Goal, Agent, Todo/obligation, Turn, and delivery fields" in rendered assert "Remove previously executed state-mutation options" in rendered for option in ( - "--next-action", "--autonomous-replan-recorded", "--repair-delta-kind", + "--next-action", "--next-action-basis", "--autonomous-replan-recorded", "--repair-delta-kind", "--usage-json", "--usage-codex-session", ): assert option in rendered diff --git a/tests/control_plane_ts/next_action_writeback.test.ts b/tests/control_plane_ts/next_action_writeback.test.ts new file mode 100644 index 0000000000..9d8391419b --- /dev/null +++ b/tests/control_plane_ts/next_action_writeback.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {resolveNextActionWriteback as resolve} from "../../loopx/control_plane/work_items/next_action_writeback.ts"; + +const source = { + goal_id: "goal-next-action", goal_revision: "sha256:" + "c".repeat(64), + state_revision: "sha256:" + "a".repeat(64), registered_agents: ["agent-a"], + next_action_entries: ["Keep the current route"], +}; +const lane = {agent_id: "agent-a", progress_scope: "agent_lane"}; +test("sole registered actor may update prose without changing report scope", () => { + assert.equal(resolve({...source, write: lane}).admitted, true); +}); +test("empty roster, unknown actor, and offline peers never qualify as a sole actor", () => { + assert.equal(resolve({...source, registered_agents: [], write: lane}).admitted, false); + assert.equal(resolve({...source, write: {...lane, agent_id: "agent-b"}}).admitted, false); + assert.equal(resolve({...source, registered_agents: ["agent-a", "offline-peer"], write: lane}).admitted, false); +}); +test("multi-peer shared writes require actor, goal scope, and exact basis", () => { + const shared = {...source, registered_agents: ["agent-a", "agent-b"]}; + const basis = resolve(shared).basis; + const write = {agent_id: "agent-b", progress_scope: "goal", expected_basis: basis}; + assert.equal(resolve({...shared, write}).admitted, true); + assert.equal(resolve({...shared, write: {...write, expected_basis: null}}).error_code, "next_action_basis_required"); + assert.equal(resolve({...shared, write: {...write, agent_id: null}}).admitted, false); + assert.equal(resolve({...shared, write: {...write, progress_scope: "agent_lane"}}).admitted, false); + assert.equal(resolve({...shared, write: {...write, source_basis: basis, expected_basis: null}}).error_code, "next_action_basis_required"); +}); +test("invocation source basis also fences admission, without becoming caller authority", () => { + assert.equal(resolve({...source, write: {...lane, source_basis: "sha256:" + "0".repeat(64)}}).error_code, "next_action_basis_conflict"); +}); +test("old prose, membership, lifecycle and instance bases conflict, including same-actor writes", () => { + const write = {...lane, expected_basis: resolve(source).basis}; + for (const change of [ + {state_revision: "sha256:" + "b".repeat(64)}, {registered_agents: ["agent-a", "agent-b"]}, + {goal_revision: "sha256:" + "d".repeat(64)}, {goal_id: "other-goal"}, + ]) { + assert.equal(resolve({...source, ...change, write}).error_code, "next_action_basis_conflict"); + } +}); +test("roster ordering and duplicate identities do not create conflicts", () => { + const shared = {...source, registered_agents: ["agent-b", "agent-a"]}; + assert.equal(resolve({...shared, registered_agents: ["agent-a", "agent-b", "agent-a"]}).basis, resolve(shared).basis); +}); +test("unscoped legacy goal write remains admitted; malformed basis fails fast", () => { + assert.equal(resolve({...source, registered_agents: [], write: {progress_scope: "goal"}}).admitted, true); + assert.throws(() => resolve({...source, write: {...lane, expected_basis: "old"}}), /SHA-256 basis/); +}); From 97b9686b74da8bb431126b5a64c95ffda94e4b10 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 21:12:26 +0800 Subject: [PATCH 3/8] docs(state): define sole-peer and shared Next Action writeback Signed-off-by: huangruiteng --- ...oal-alignment-and-governed-amendment-v0.md | 8 +++++ ...ignment-and-governed-amendment-v0.zh-CN.md | 6 ++++ docs/project-agent-todo-contract.md | 6 ++++ docs/quota-allocation.md | 32 ++++++++++++++++--- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md index c740491efc..4396f5143e 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md @@ -567,6 +567,14 @@ provider-neutral aggregate requires a separate reviewed transaction boundary. `Next Action` remains compatibility prose and a read projection. It is never a claim, lease, Goal amendment, replan settlement, or authority decision. +Compatibility-prose checkpoint: `refresh-state` admits a confirmed sole +registered peer's Next Action write independently of its personal report scope. +Multi-peer reads compose the existing Todo routes; explicit shared-prose writes +require Goal report scope and an exact read basis, rechecked with membership at +commit. See the [writeback contract](../../quota-allocation.md). This does not +advance Stage 3 amendment commit: canonical task mutations, lease/claim checks, +protected intent and acceptance remain with their existing owners. + ### 9.1 Semantic handoff and execution-route integration Use the existing alignment projection to supply a receiver's actual work diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md index 9820962600..8c28c176c7 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md @@ -489,6 +489,12 @@ projection 与 proposal contract 交付;把 commit 映射进 provider-neutral `Next Action` 继续是 compatibility prose 与 read projection。它永远不是 claim、 lease、Goal amendment、replan settlement 或 authority decision。 +兼容文本写回 checkpoint:`refresh-state` 确认完整注册名单只有当前 peer 时, +允许其在个人进展 scope 中更新 Next Action,保留 agent 归因。多 peer 路线由现有 +Todo selector 汇总;显式共享文本更新仍需 Goal scope 和当前读取依据,并在提交时 +重新检查成员与版本。详见[写回合同](../../quota-allocation.md)。这不推进 Stage 3 +amendment commit;任务修改、claim/lease、受保护意图和验收仍由原 owner 负责。 + ### 9.1 语义交接与执行路线衔接 用既有 alignment 投影给接收方提供真实工作基线。意图内的路线重规划仍走接收方 diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index f0cb49cbc0..45bdb7c0df 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -553,6 +553,12 @@ The default scoped refresh is an agent-lane run: it is useful for keeping the same turn's writeback/accounting identity intact, but it does not replace the goal-level status route. +A confirmed sole registered peer may still update durable compatibility prose +with `--next-action` in that personal report. Multi-peer shared-prose updates +require Goal scope and a current `--next-action-basis`; the read projection +preserves each peer's Todo route. See [Next Action writeback](quota-allocation.md) +for attribution, snapshot conflicts and the unchanged authority boundaries. + ## Lifecycle Contract Agents should not patch active-state checkboxes directly to move work forward. diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index d598ac7567..0a59a17861 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -1121,10 +1121,34 @@ lane-local quiet or wait decisions. Its `autonomous_replan_decision` says that a required replan must be selected independently of `monitor_quiet_skip` or `agent_scope_wait`; the policy lives in `loopx.control_plane.goals.goal_frontier`, while quota only wires the selected mode into `interaction_contract`. -If the active-state and latest-run actions differ, -`next_action_projection_warning` asks the executor to explicitly write back the -intended durable route with a primary goal-scope `refresh-state --next-action` -or keep treating the signals as distinct. +If the active-state and latest-run actions differ, `next_action_projection_warning` +keeps the agent's Todo route distinct from shared compatibility prose. A confirmed +sole registered peer can use `refresh-state --agent-id PEER --next-action TEXT` +without promoting its personal report: `progress_scope` remains `agent_lane`. +Confirmation uses the complete registered roster, including offline peers and +legacy roster sources, rather than presence. Shared registry reads resolve to +the project source registry; a stale mirror cannot grant sole-peer authority. +An unavailable source cannot supply a write basis. Attribution, Vision checkpoints, +settlement, workspace and prose-writer checks still apply. + +For multiple peers, `status --include-task-graph` projects `agent_next_actions` from each peer's existing +Todo selector; changing one route does not replace the others or rewrite the +durable prose. Ordinary Todo and Vision edits keep their existing owners. +An explicit shared prose replacement requires a registered actor, +`--progress-scope goal` and `--next-action-basis BASIS`, where `BASIS` is +`attention_queue.items[].next_action_basis` from `loopx --format json status --goal-id GOAL`. +The same flag is available to a sole peer to reject a plan made against an older +read. Without the flag, sole-peer writes capture their basis at invocation time; +they cannot detect an older planning read outside that invocation. + +The basis binds the complete active-state bytes and registry Goal identity, +membership, lifecycle, instance and routing facts. A stale write +returns `next_action_basis_conflict`, the current basis and a bounded Next Action +readback; reread and rejudge rather than repeating task work or quota spend. +The writer rechecks admission under registry/state locks immediately before +the prose write. This is a compatibility-prose fence, not a canonical Todo +transaction or a governed Goal amendment. Unscoped legacy Goal reports and +refreshes without `--next-action` retain their previous behavior. `refresh-state` records `recommended_action_source` so hosts can tell whether a run recommendation came from an explicit argument, durable `## Next Action`, an Agent Todo compatibility fallback, or the generic default. Dispatch still comes From 74bfadb5dd2e2cedf457e8a8820c612def6b7a04 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 23:25:28 +0800 Subject: [PATCH 4/8] refactor(state): bind next steps through the existing recommendation owner Signed-off-by: huangruiteng --- demo/auto_research/demo_e2e.py | 15 +- .../project_lifecycle_refresh_state.py | 16 +- loopx/cli_commands/status.py | 2 + .../agents/agent_lane_recommendation.py | 14 +- .../control_plane/effect_runtime_handlers.ts | 9 +- .../goals/goal_frontier/semantic_history.py | 14 ++ .../control_plane/quota/should_run_packet.py | 20 +++ loopx/control_plane/runtime/run_compaction.py | 4 + .../runtime/run_context_retention.py | 5 + .../control_plane/todos/active_state_todos.py | 44 ++--- loopx/control_plane/todos/next_action.ts | 9 ++ .../todos/next_action_runtime.py | 12 ++ .../todos/projection_document.py | 13 ++ .../todos/provider_projection.py | 11 +- .../work_items/next_action_writeback.ts | 55 ------- .../work_items/next_action_writeback_io.py | 99 ------------ .../work_items/primary_action.py | 2 +- .../work_items/recommendation_source_io.py | 137 ++++++++++++++++ .../work_items/refresh_recommendation.py | 34 +++- .../work_items/refresh_recommendation.ts | 82 ++++++++++ .../lark/presentation/projection_rows.py | 6 +- .../presentation/renderers/quota_markdown.py | 2 + .../presentation/renderers/status_markdown.py | 4 + loopx/quota.py | 1 + .../project_registry_io_manifest_v1.json | 2 +- loopx/state_projection.py | 9 +- loopx/state_refresh.py | 150 ++++-------------- loopx/status.py | 2 - 28 files changed, 421 insertions(+), 352 deletions(-) delete mode 100644 loopx/control_plane/work_items/next_action_writeback.ts delete mode 100644 loopx/control_plane/work_items/next_action_writeback_io.py create mode 100644 loopx/control_plane/work_items/recommendation_source_io.py diff --git a/demo/auto_research/demo_e2e.py b/demo/auto_research/demo_e2e.py index b4b181ba13..f36f8e902f 100644 --- a/demo/auto_research/demo_e2e.py +++ b/demo/auto_research/demo_e2e.py @@ -118,13 +118,12 @@ def _seed_visible_demo_control_plane( from loopx.bootstrap import bootstrap_project from loopx.configure_goal import configure_goal - from loopx.state_refresh import now_local, replace_next_action_section from loopx.todos import add_goal_todo control_project = demo_root / "visible-control-plane" control_registry = demo_root / "visible-control-plane.registry.json" control_runtime = demo_root / "visible-control-plane.runtime" - bootstrap = bootstrap_project( + bootstrap_project( project=control_project, registry_path=control_registry, runtime_root=control_runtime, @@ -146,18 +145,6 @@ def _seed_visible_demo_control_plane( dry_run=False, sync_global=False, ) - state_file = Path(str(bootstrap["state_file"])) - if state_file.exists(): - updated_state, state_changed = replace_next_action_section( - state_file.read_text(encoding="utf-8"), - next_action=( - "Goal-level route delegates to role frontier; panes own execution." - ), - updated_at=now_local(), - ) - if state_changed: - state_file.write_text(updated_state, encoding="utf-8") - lanes = [lane for lane in supervisor.get("lanes") or [] if isinstance(lane, dict)] agents = sorted( { diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 896c0ebc82..1592da195d 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -125,17 +125,17 @@ def register_refresh_state_command( refresh_state_parser.add_argument( "--next-action", help=( - "Explicitly update the active state's durable ## Next Action before " - "appending the refresh run. Without this flag, --recommended-action " - "only describes the run record." + "Record a next step bound to this agent's selected advancement Todo in " + "the existing recommendation receipt. Does not overwrite Markdown " + "Next Action, select another task, or grant execution authority." ), ) refresh_state_parser.add_argument( "--next-action-basis", help=( - "Next Action read basis from status attention_queue.items[].next_action_basis. " - "Required for shared multi-agent writes; optional for sole-peer writes " - "to reject an older planning snapshot." + "Current agent's Next Action basis from status --agent-id or the quota " + "agent_lane_next_action. Rejects a stale task or same-agent step. " + "Without it, the command uses a fresh invocation read." ), ) refresh_state_parser.add_argument( @@ -339,8 +339,8 @@ def register_refresh_state_command( choices=PROGRESS_SCOPE_CHOICES, help=( "Progress report scope; --agent-id defaults to agent_lane. A confirmed " - "sole peer may update Next Action without changing that scope. Shared " - "multi-agent prose updates require goal scope and --next-action-basis." + "registered peer may update its selected task step without changing " + "that scope; report scope does not grant shared task authority." ), ) refresh_state_parser.add_argument( diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index 89d9d6e72f..27089beda5 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -684,6 +684,8 @@ def attach_agent_lane_next_actions( except Exception: continue next_action = guard.get("agent_lane_next_action") + if isinstance(next_action, dict) and next_action.get("next_action_basis"): + item["next_action_basis"] = next_action["next_action_basis"] project_asset = item.get("project_asset") agent_member = _build_agent_member_projection( item, diff --git a/loopx/control_plane/agents/agent_lane_recommendation.py b/loopx/control_plane/agents/agent_lane_recommendation.py index c199c57937..de375c518a 100644 --- a/loopx/control_plane/agents/agent_lane_recommendation.py +++ b/loopx/control_plane/agents/agent_lane_recommendation.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from collections.abc import Callable from typing import Any @@ -397,13 +396,6 @@ def _first_executable_todo_text(agent_todo_summary: dict[str, Any] | None) -> st return None -def _todo_ids_from_action(value: Any) -> set[str]: - text = str(value or "") - if not text: - return set() - return set(re.findall(r"\btodo_[A-Za-z0-9_]+\b", text)) - - def selected_recommended_action_from_work_lane( item: dict[str, Any], *, @@ -551,7 +543,7 @@ def build_agent_lane_next_action( ) ) - preferred_todo_ids = _todo_ids_from_action(active_next_action) + preferred_todo_ids: set[str] = set() receipt_todo_id = normalize_todo_id(receipt_bound_todo_id) override_todo_id = ( normalize_todo_id(selected_todo_override.get("todo_id")) @@ -690,6 +682,8 @@ def selected_action_with_agent_lane( ) -> Any: if not isinstance(agent_lane_next_action, dict): return selected_action + if agent_lane_next_action.get("next_step"): + return agent_lane_next_action["next_step"] if agent_lane_next_action.get("source") not in { "capability_gate.runnable_candidates", "agent_todo_summary.active_next_action_executable_items", @@ -708,5 +702,5 @@ def selected_action_with_agent_lane( return selected_action if confidence not in {"selected", "candidate"}: return selected_action - lane_text = str(agent_lane_next_action.get("text") or "").strip() + lane_text = str(agent_lane_next_action.get("next_step") or agent_lane_next_action.get("text") or "").strip() return lane_text or selected_action diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 43fa9eeaf8..4e3419a4cc 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -118,7 +118,7 @@ import { selectTodoCompletionContinuation, } from "./todos/completion_state.ts"; import { reduceTodoCompletionTransaction } from "./todos/completion_transaction.ts"; -import { transitionTodoNextAction } from "./todos/next_action.ts"; +import { transitionTodoNextAction, projectNextActionBinding } from "./todos/next_action.ts"; import { planTodoFieldUpdate } from "./todos/field_update.ts"; import { planPublicTodoUpdate } from "./todos/public_update.ts"; import { planMonitorMetadata } from "./todos/monitor_metadata.ts"; @@ -234,8 +234,8 @@ import { projectTodoPlanningInventory, projectTodoPlanningInventoryDetail, } from "./work_items/planning_inventory.ts"; -import { resolveRefreshRecommendation } from "./work_items/refresh_recommendation.ts"; -import {resolveNextActionWriteback} from "./work_items/next_action_writeback.ts"; +import { resolveRefreshRecommendation, resolveLaneRecommendation } from "./work_items/refresh_recommendation.ts"; + import { validateInteractionProjectionHookInvocation, validateInteractionProjectionHookRegistration, @@ -533,6 +533,7 @@ export function createEffectRuntimeHandlers( ["todo.successor.derive", evaluateCoordinationTodoSuccessorDerivation], ["todo.completion.reduce", reduceTodoCompletionTransaction], ["todo.next_action.transition", transitionTodoNextAction], + ["todo.next_action.binding", projectNextActionBinding], ["todo.resume_condition.normalize", normalizeTodoResumeWhen], ["todo.resume_condition.evaluate", evaluateTodoResumeConditions], ["todo.resume_planning.project", projectTodoResumePlanning], @@ -562,7 +563,7 @@ export function createEffectRuntimeHandlers( ["work_item.planning_inventory.project", projectTodoPlanningInventory], ["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail], ["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation], - ["work_item.next_action_writeback.resolve", resolveNextActionWriteback], + ["work_item.refresh_recommendation.lane", resolveLaneRecommendation], ["work_item.delivery_history.project", projectDeliveryHistory], ["work_item.delivery_response.project", projectDeliveryResponse], ["work_item.delivery_claim.validate", validateDeliveryClaim], diff --git a/loopx/control_plane/goals/goal_frontier/semantic_history.py b/loopx/control_plane/goals/goal_frontier/semantic_history.py index 0beef33c7f..c6dc1fe85d 100644 --- a/loopx/control_plane/goals/goal_frontier/semantic_history.py +++ b/loopx/control_plane/goals/goal_frontier/semantic_history.py @@ -270,3 +270,17 @@ def latest_replan_ack_feedback_from_status_payload( if isinstance(feedback, dict): return feedback return None + + +def latest_lane_recommendation_from_status( + status_payload: dict[str, Any], *, goal_id: str, agent_id: str, +) -> dict[str, Any] | None: + """Reuse the retained recommendation receipt beyond the recent-run window.""" + present, context = _semantic_agent_context_for_goal( + status_payload, goal_id=goal_id, agent_id=agent_id) + if present: + row = context.get("latest_lane_step_run") if isinstance(context, dict) else None + resolution = row.get("recommended_action_resolution") if isinstance(row, dict) else None + return resolution if isinstance(resolution, dict) else None + from ...work_items.recommendation_source_io import latest_bound_recommendation + return latest_bound_recommendation(_latest_runs_for_goal(status_payload, goal_id=goal_id), agent_id) diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 879be48ad7..6b8ba204a4 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -798,6 +798,25 @@ def _resolve_external_evidence_observation( return external_evidence_observation, external_evidence_observation_recent +def _with_lane_recommendation( + prepared: _QuotaDecisionPreparation, agent_lane_next_action: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Decorate selected work; preserve replay's frozen execution packet.""" + source = prepared.item.get("recommendation_context") + if isinstance(source, dict) and isinstance(agent_lane_next_action, dict) and not prepared.guarded_agent_lane_next_action: + from ..goals.goal_frontier.semantic_history import latest_lane_recommendation_from_status + from ..work_items.recommendation_source_io import lane_recommendation_context + actor = agent_lane_next_action.get("agent_id") + task = next((task for task in prepared.agent_todo_planning_source_items + if task.get("todo_id") == agent_lane_next_action.get("todo_id")), None) + context = lane_recommendation_context(source, agent_id=actor, + selected_todo=agent_lane_next_action, task=task, + prior_resolution=latest_lane_recommendation_from_status( + prepared.status_payload, goal_id=prepared.safe_goal_id, agent_id=actor)) + agent_lane_next_action = context["selected_todo"] + return agent_lane_next_action + + def _resolve_quota_should_run_route( prepared: _QuotaDecisionPreparation, ) -> _QuotaDecisionRoute: @@ -1043,6 +1062,7 @@ def _resolve_quota_should_run_route( should_run=should_run, normal_delivery_allowed=normal_delivery_allowed, ) + agent_lane_next_action = _with_lane_recommendation(prepared, agent_lane_next_action) agent_scope_frontier = None agent_lane_frontier_hint = None if receipt_bound_deferred: diff --git a/loopx/control_plane/runtime/run_compaction.py b/loopx/control_plane/runtime/run_compaction.py index 89996e8347..2774080f8d 100644 --- a/loopx/control_plane/runtime/run_compaction.py +++ b/loopx/control_plane/runtime/run_compaction.py @@ -357,6 +357,10 @@ def compact_run_base( compact["subagents"] = subagents[:max_subagent_activity_items] compact["subagent_count"] = len(subagents) + if isinstance(run.get("recommended_action_resolution"), dict): + resolution = run["recommended_action_resolution"] + if resolution.get("step_revision"): + compact["recommended_action_resolution"] = dict(resolution) return compact diff --git a/loopx/control_plane/runtime/run_context_retention.py b/loopx/control_plane/runtime/run_context_retention.py index a62957fa7b..42dd03bbe3 100644 --- a/loopx/control_plane/runtime/run_context_retention.py +++ b/loopx/control_plane/runtime/run_context_retention.py @@ -18,6 +18,7 @@ GOAL_SEMANTIC_HISTORY_SCHEMA_VERSION = "goal_semantic_history_v0" SEMANTIC_CONTEXT_RUN_FIELDS = ( + "latest_lane_step_run", "latest_agent_vision_run", "latest_vision_checkpoint_run", "latest_outcome_vision_checkpoint_run", @@ -27,6 +28,7 @@ "latest_evidence_delivery_run", ) SEMANTIC_CONTEXT_RUN_PAYLOAD_FIELDS = { + "latest_lane_step_run": ("generated_at", "goal_id", "agent_id", "recommended_action_resolution"), "latest_agent_vision_run": ( "generated_at", "agent_id", @@ -161,6 +163,9 @@ def goal_semantic_history_from_runs( active_blocked_retry_runs.append(run) context = contexts.setdefault(agent_id, {"agent_id": agent_id}) + resolution = run.get("recommended_action_resolution") + if "latest_lane_step_run" not in context and isinstance(resolution, dict) and resolution.get("step_revision"): + context["latest_lane_step_run"] = run checkpoint = run.get("vision_checkpoint") if "latest_vision_checkpoint_run" not in context and isinstance( checkpoint, dict diff --git a/loopx/control_plane/todos/active_state_todos.py b/loopx/control_plane/todos/active_state_todos.py index a2d966c5e0..cc7a525e5b 100644 --- a/loopx/control_plane/todos/active_state_todos.py +++ b/loopx/control_plane/todos/active_state_todos.py @@ -12,8 +12,9 @@ from .succession_warning import public_todo_summary from ...agent_registry import registered_agent_ids_for_goal -from ..work_items.next_action_writeback_io import ( - load_next_action_source_goal, next_action_writeback_context, project_agent_next_actions, +from ..work_items.recommendation_source_io import ( + load_recommendation_source_goal, recommendation_source_context, + project_agent_next_actions, recommendation_runs, ) def _redacted_status_todo_fields(fields: dict[str, Any]) -> dict[str, Any]: @@ -60,7 +61,6 @@ def active_state_todo_fields( rollout_events: Sequence[Mapping[str, Any]] | None = None, resolve_goal_local_path: Callable[..., Path | None], active_state_next_action_entries: Callable[..., list[str]], - active_next_action_todo_ids: Callable[[str], set[str]], load_rollout_events: Callable[..., list[dict[str, Any]]], rollout_event_log_path: Callable[[Path, str], Path], max_todo_index_rollout_events_per_goal: int, @@ -77,7 +77,7 @@ def active_state_todo_fields( admission_goal: dict[str, Any] | None = goal if registry_path is not None and goal_id: try: - source_registry, source_goal = load_next_action_source_goal(registry_path, goal_id) + source_registry, source_goal = load_recommendation_source_goal(registry_path, goal_id) except (OSError, ValueError): # Status remains a read model when its source is unavailable. It # must not mint a write basis from a stale shared roster. @@ -99,10 +99,8 @@ def active_state_todo_fields( require_no_legacy_todo_events(goal, state_path=state_path) if canonical is None and (state_path is None or not state_path.exists()): return {} - state_readable = False try: state_text = state_path.read_text(encoding="utf-8") if state_path is not None else "" - state_readable = state_path is not None except OSError: if canonical is None: return {} @@ -112,9 +110,8 @@ def active_state_todo_fields( raise state_text = "" next_action_entries = active_state_next_action_entries(state_text, limit=3) - preferred_todo_ids: set[str] = set() - for entry in next_action_entries: - preferred_todo_ids.update(active_next_action_todo_ids(entry)) + from .next_action_runtime import bound_next_action_todo_ids + preferred_todo_ids = bound_next_action_todo_ids(state_text) events = ( [dict(event) for event in rollout_events] if rollout_events is not None @@ -151,17 +148,24 @@ def active_state_todo_fields( if next_action_entries: fields["active_state_next_action"] = next_action_entries[0] fields["active_state_next_action_entries"] = next_action_entries - if goal_id and state_readable and admission_goal is not None: - fields["next_action_basis"] = next_action_writeback_context(admission_goal, state_text, source_registry=source_registry)["basis"] - # This is a read projection, not a rewrite of shared compatibility prose. - # Select from the complete task source, never a bounded status page. - if include_agent_next_actions and len(registered_agent_ids_for_goal(admission_goal)) > 1: - route_fields = fields if canonical is not None else parse_active_state_todos( - state_text, goal=goal, state_path=state_path, rollout_events=events, item_limit=None, - ) - routes = project_agent_next_actions(goal, route_fields) - if routes: - fields["agent_next_actions"] = routes + if goal_id and admission_goal is not None: + source = recommendation_source_context(admission_goal, state_text, + source_registry=source_registry, todo_fields=fields if canonical is not None else None) + fields["recommendation_context"] = source + sole_agent = len(registered_agent_ids_for_goal(admission_goal)) == 1 + # Default multi-agent status needs source facts, not an RPC and full + # journal scan per peer. Detail and sole-agent readback request routes. + if include_agent_next_actions or sole_agent: + # Binding uses full task facts, never a bounded display page. + route_fields = fields if canonical is not None else parse_active_state_todos( + state_text, goal=goal, state_path=state_path, rollout_events=events, item_limit=None, + ) + runs = recommendation_runs(runtime_root, goal_id) if runtime_root else [] + routes = project_agent_next_actions(goal, route_fields, source=source, runs=runs) + if sole_agent and routes: + fields["next_action_basis"] = routes[0]["next_action_basis"] + if include_agent_next_actions and routes: + fields["agent_next_actions"] = routes warning = backlog_hygiene_warning( state_text, agent_todos=fields.get("agent_todos") if isinstance(fields.get("agent_todos"), dict) else None, diff --git a/loopx/control_plane/todos/next_action.ts b/loopx/control_plane/todos/next_action.ts index 559bf71267..4ad44af4c6 100644 --- a/loopx/control_plane/todos/next_action.ts +++ b/loopx/control_plane/todos/next_action.ts @@ -267,6 +267,15 @@ function bindingMatches(lines: readonly string[]): BindingMatch[] { return matches; } +/** Decode the existing typed breadcrumb; arbitrary prose cannot select work. */ +export function projectNextActionBinding(value: unknown): JsonObject { + const request = requiredObject(value, "next action binding"); + const lines = stringArray(request.lines, "next action binding lines"); + const matches = bindingMatches(lines); + return {todo_id: matches.length === 1 && matches[0].schema === NEXT_ACTION_BINDING_SCHEMA && + visibleNextActionEntries(lines).length === 1 ? matches[0].todoId : null}; +} + function hasBindingDirective(lines: readonly string[]): boolean { const bounds = headingBounds(lines, "Next Action"); if (!bounds) return false; diff --git a/loopx/control_plane/todos/next_action_runtime.py b/loopx/control_plane/todos/next_action_runtime.py index 277bdc62ea..907dacb6a2 100644 --- a/loopx/control_plane/todos/next_action_runtime.py +++ b/loopx/control_plane/todos/next_action_runtime.py @@ -221,3 +221,15 @@ def settle_completed_todo_next_action( }, ) return bool(result["changed"]) + + +def bound_next_action_todo_ids(state_text: str) -> set[str]: + """Decode the existing typed binding, without inferring IDs from prose.""" + if "loopx:next-action" not in state_text: + return set() + lines = state_text.splitlines() + bounds = _next_action_projection_bounds(lines) + if bounds is None: + return set() + result = effect_runtime_result("todo.next_action.binding", {"lines": lines[bounds[0]:bounds[1]]}) + return {result["todo_id"]} if result.get("todo_id") else set() diff --git a/loopx/control_plane/todos/projection_document.py b/loopx/control_plane/todos/projection_document.py index 7893430da7..d8c7e17efb 100644 --- a/loopx/control_plane/todos/projection_document.py +++ b/loopx/control_plane/todos/projection_document.py @@ -1,10 +1,23 @@ """Parse replaceable Todo regions once, preserving every byte of surrounding prose.""" from collections.abc import Mapping from dataclasses import dataclass +import json from .machine_region import TodoRegion, find_todo_regions, visible_markdown_lines +def recovered_todo_projection_skeleton(goal_id: str) -> str: + """The display recovery codec; it does not restore missing Goal intent.""" + return ( + f"---\ngoal_id: {json.dumps(goal_id, ensure_ascii=False)}\n---\n\n" + "# Recovered Todo projection\n\n" + "> Regenerated from canonical Todo authority. Non-Todo sections " + "are not in this provider snapshot and were not recovered. " + "This is a Todo projection, not a complete Goal-state restore.\n\n" + "## Agent Todo\n" + ) + + @dataclass(frozen=True) class TodoProjectionDocument: markdown: str diff --git a/loopx/control_plane/todos/provider_projection.py b/loopx/control_plane/todos/provider_projection.py index 39fe2c2ed1..740e182aec 100644 --- a/loopx/control_plane/todos/provider_projection.py +++ b/loopx/control_plane/todos/provider_projection.py @@ -10,7 +10,6 @@ from __future__ import annotations -import json from enum import StrEnum from collections.abc import Mapping from pathlib import Path @@ -30,6 +29,7 @@ ) from .completion_validation_store import load_completion_validation_declarations from .active_state_editing import atomic_write_state_text, verify_state_text_durable +from .projection_document import recovered_todo_projection_skeleton TODO_PROJECTION_DELIVERY_SCHEMA = "loopx_todo_projection_delivery_v0" @@ -131,14 +131,7 @@ def project_current_canonical_todos( except FileNotFoundError: recovered_missing = True missing_this_attempt = True - source = ( - f"---\ngoal_id: {json.dumps(goal_id, ensure_ascii=False)}\n---\n\n" - "# Recovered Todo projection\n\n" - "> Regenerated from canonical Todo authority. Non-Todo sections " - "are not in this provider snapshot and were not recovered. " - "This is a Todo projection, not a complete Goal-state restore.\n\n" - "## Agent Todo\n" - ) + source = recovered_todo_projection_skeleton(goal_id) projection = render_canonical_todo_sections( source, authority_read["todos"], diff --git a/loopx/control_plane/work_items/next_action_writeback.ts b/loopx/control_plane/work_items/next_action_writeback.ts deleted file mode 100644 index cd52408a43..0000000000 --- a/loopx/control_plane/work_items/next_action_writeback.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** Admission for compatibility prose only, never Todo or Goal amendment authority. */ -import {createHash} from "node:crypto"; -import {ENVELOPED_SHA256_PATTERN} from "../content_digest.ts"; -import {type JsonObject} from "../effect_program.ts"; -import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; -import {optionalNonEmptyString, requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; - -export function resolveNextActionWriteback(value: unknown): JsonObject { - const request = requireJsonObject(value, "next_action_writeback"); - const goalId = requireNonEmptyString(request.goal_id, "goal_id"); - const revision = requireNonEmptyString(request.state_revision, "state_revision"); - const goalRevision = requireNonEmptyString(request.goal_revision, "goal_revision"); - if (!ENVELOPED_SHA256_PATTERN.test(revision) || !ENVELOPED_SHA256_PATTERN.test(goalRevision)) { - throw new EffectRuntimeRequestError("state_revision and goal_revision must be SHA-256 revisions"); - } - // The adapter supplies the complete normalized roster, including offline peers. - const agents = [...new Set(requireStringArray(request.registered_agents, "registered_agents"))].sort(); - const entries = requireStringArray(request.next_action_entries, "next_action_entries"); - const basis = "sha256:" + createHash("sha256").update(JSON.stringify([ - goalId, goalRevision, agents, revision, - ])).digest("hex"); - const context: JsonObject = {basis, registered_agents: agents, next_action_entries: entries}; - if (request.write === undefined || request.write === null) return context; - const write = requireJsonObject(request.write, "write"); - const actor = optionalNonEmptyString(write.agent_id, "agent_id"); - const scope = requireNonEmptyString(write.progress_scope, "progress_scope"); - const expected = optionalNonEmptyString(write.expected_basis, "expected_basis"); - const sourceBasis = optionalNonEmptyString(write.source_basis, "source_basis"); - if (scope !== "goal" && scope !== "agent_lane") { - throw new EffectRuntimeRequestError("progress_scope must be goal or agent_lane"); - } - if (expected && !ENVELOPED_SHA256_PATTERN.test(expected)) { - throw new EffectRuntimeRequestError("--next-action-basis must be a SHA-256 basis"); - } - const reject = (code: string, error: string): JsonObject => ({ - ...context, admitted: false, error_code: code, error, reread_required: true, - }); - if (actor && agents.length && !agents.includes(actor)) { - return reject("next_action_actor_unregistered", "Next Action writer is not registered for this Goal"); - } - if ((expected && expected !== basis) || (sourceBasis && sourceBasis !== basis)) { - return reject("next_action_basis_conflict", "Next Action read basis changed; read current status and rejudge before retrying"); - } - if (agents.length > 1 && !actor) { - return reject("next_action_actor_required", "multi-agent Next Action write requires --agent-id"); - } - if (scope === "agent_lane" && !(actor && agents.length === 1 && agents[0] === actor)) { - return reject("next_action_shared_scope_required", - "agent-lane refresh-state cannot update shared Next Action without a confirmed sole registered peer; update your Todo route, or use --progress-scope goal with --next-action-basis"); - } - if (agents.length > 1 && !expected) { - return reject("next_action_basis_required", "shared Next Action write requires --next-action-basis from current status"); - } - return {...context, admitted: true}; -} diff --git a/loopx/control_plane/work_items/next_action_writeback_io.py b/loopx/control_plane/work_items/next_action_writeback_io.py deleted file mode 100644 index 9dab11ffd9..0000000000 --- a/loopx/control_plane/work_items/next_action_writeback_io.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Source I/O for TS-owned Next Action admission; no task/intent write authority.""" -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import ExitStack, contextmanager -from pathlib import Path -from typing import Any - -from ...agent_registry import load_goal_from_registry, registered_agent_ids_for_goal -from ...file_lock import exclusive_cross_runtime_file_lock -from ...state_projection import active_state_next_action_entries -from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result -from ..runtime.local_state_write_correctness import active_state_revision, stable_write_digest -from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route - - -class NextActionWritebackRejected(ValueError): - def __init__(self, result: dict[str, Any]) -> None: - super().__init__(result["error"]) - self.code = result["error_code"] - self.payload = {"next_action_writeback": result} - - -def next_action_writeback_context( - goal: dict[str, Any] | None, state_text: str, *, goal_id: str | None = None, - source_registry: Path | None = None, - write: dict[str, Any] | None = None, -) -> dict[str, Any]: - source = goal if goal is not None else {"id": goal_id} - request = { - "goal_id": source["id"], - # Bind registry identity/lifecycle/routing facts, not status's derived - # run history, projections or display defaults. - "goal_revision": "sha256:" + stable_write_digest({ - "registered_goal_present": goal is not None, - "source_registry": str(source_registry.resolve()) if source_registry is not None else None, - "facts": {key: source.get(key) for key in ("id", "goal_instance_id", "status", "repo", "state_file")}, - }), - "registered_agents": registered_agent_ids_for_goal(goal), - "state_revision": active_state_revision(state_text)["value"], - # A bounded readback, but the revision above covers the complete bytes. - "next_action_entries": active_state_next_action_entries(state_text, limit=3, text_limit=500), - "write": write, - } - try: - result = effect_runtime_result("work_item.next_action_writeback.resolve", request) - except EffectRuntimeRejected as error: - raise ValueError(str(error)) from error - if not isinstance(result, dict) or not isinstance(result.get("basis"), str): - raise RuntimeError("invalid TypeScript Next Action admission result") - if write is not None and result.get("admitted") is not True: - raise NextActionWritebackRejected(result) - return result - - -def load_next_action_source_goal(registry_path: Path, goal_id: str) -> tuple[Path, dict[str, Any] | None]: - # Runtime overrides choose execution/projection, never roster authority. - route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) - source_registry = Path(route["source_registry"]).resolve() - return source_registry, load_goal_from_registry(source_registry, goal_id) - - -def project_agent_next_actions(goal: dict[str, Any], todo_fields: dict[str, Any]) -> list[dict[str, Any]]: - """Compose independent existing lane selectors; never infer a new owner.""" - from ..agents.agent_lane_recommendation import build_agent_lane_next_action - - agents = registered_agent_ids_for_goal(goal) - if len(agents) < 2: - return [] - routes = [] - for agent in agents: - route = build_agent_lane_next_action( - agent_identity={"agent_id": agent}, agent_todo_summary=todo_fields.get("agent_todos"), - capability_gate=None, active_next_action=[], - ) - if route: - routes.append({key: route[key] for key in ("agent_id", "todo_id", "text") if key in route}) - return routes - - -@contextmanager -def next_action_source_guard( - registry_path: Path, state_path: Path, goal_id: str, - *, source_registry: Path, -) -> Iterator[tuple[dict[str, Any] | None, str]]: - # Registration/configuration use the same registry lock. Quota's outer - # index/source guard precedes this short registry -> state critical section. - # Release before projection/global sync to avoid recursive registry locking. - # Configuration locks source before its shared projection. Keep that order, - # and revalidate the projection's route under both locks before using it. - with ExitStack() as stack: - stack.enter_context(exclusive_cross_runtime_file_lock(source_registry, operation="next-action-writeback")) - if registry_path.resolve() != source_registry.resolve(): - stack.enter_context(exclusive_cross_runtime_file_lock(registry_path, operation="next-action-writeback")) - current_source, goal = load_next_action_source_goal(registry_path, goal_id) - if current_source != source_registry.resolve(): - raise ValueError("Next Action source registry changed; read current status and rejudge before retrying") - stack.enter_context(exclusive_cross_runtime_file_lock(state_path, operation="next-action-writeback")) - yield goal, state_path.read_text(encoding="utf-8") diff --git a/loopx/control_plane/work_items/primary_action.py b/loopx/control_plane/work_items/primary_action.py index 28d78a94d4..c91efaa0e1 100644 --- a/loopx/control_plane/work_items/primary_action.py +++ b/loopx/control_plane/work_items/primary_action.py @@ -39,7 +39,7 @@ def protocol_first_candidate_action(payload: dict[str, Any]) -> str | None: if isinstance(payload.get("agent_lane_next_action"), dict) else {} ) - lane_text = protocol_action_label(agent_lane_next_action.get("text")) + lane_text = protocol_action_label(agent_lane_next_action.get("next_step") or agent_lane_next_action.get("text")) if lane_text: todo_id = str(agent_lane_next_action.get("todo_id") or "").strip() return f"{todo_id}: {lane_text}" if todo_id else lane_text diff --git a/loopx/control_plane/work_items/recommendation_source_io.py b/loopx/control_plane/work_items/recommendation_source_io.py new file mode 100644 index 0000000000..8692719567 --- /dev/null +++ b/loopx/control_plane/work_items/recommendation_source_io.py @@ -0,0 +1,137 @@ +"""Source I/O for the existing TS recommendation owner; no task write authority.""" +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import ExitStack, contextmanager +from pathlib import Path +from typing import Any + +from ...agent_registry import load_goal_from_registry, registered_agent_ids_for_goal +from ...file_lock import exclusive_cross_runtime_file_lock +from ...history import load_index +from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result +from ..runtime.local_state_write_correctness import stable_write_digest +from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ..runtime.time import chronology_key +from ..todos.frontier_revision import FRONTIER_REVISION_FIELDS +from ..todos.projection_document import TodoProjectionDocument + + +class RecommendationWritebackRejected(ValueError): + def __init__(self, result: dict[str, Any]) -> None: + super().__init__(result["error"]) + self.code = result["error_code"] + self.payload = {"recommendation_writeback": result} + + +def load_recommendation_source_goal(registry_path: Path, goal_id: str) -> tuple[Path, dict[str, Any] | None]: + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + source_registry = Path(route["source_registry"]).resolve() + return source_registry, load_goal_from_registry(source_registry, goal_id) + + +def recommendation_source_context(goal: dict[str, Any], state_text: str, *, source_registry: Path | None, todo_fields: dict[str, Any] | None = None) -> dict[str, Any]: + # This is a source-facts read fence, never a canonical Goal intent revision. + # Todo regions and compatibility Next Action are not independent intent. + if not state_text and todo_fields is not None: + from ..todos.projection_document import recovered_todo_projection_skeleton + # Read the same degraded display the canonical projection owner will + # deliver. Repairing a missing display is not an intent/step transition. + state_text = recovered_todo_projection_skeleton(goal["id"]) + narrative = TodoProjectionDocument.parse(state_text).narrative + lines = narrative.splitlines() + preserved = [] + in_next_action = False + for line in lines: + if line.startswith("## "): + in_next_action = line.strip() == "## Next Action" + if not in_next_action and not line.startswith("updated_at:"): + preserved.append(line) + return { + "goal_id": goal["id"], "registered_agents": registered_agent_ids_for_goal(goal), + "source_revision": "sha256:" + stable_write_digest({ + "source_registry": str(source_registry.resolve()) if source_registry else None, + "goal": {key: goal.get(key) for key in ("id", "goal_instance_id", "status", "repo", "state_file", "authority_sources")}, + "narrative": "\n".join(preserved).strip(), + "acceptance_contract": ((todo_fields or {}).get("agent_todos") or {}).get("goal_acceptance_contract"), + }), + } + + +def recommendation_runs(runtime_root: Path, goal_id: str) -> list[dict[str, Any]]: + rows, _ = load_index(runtime_root / "goals" / goal_id / "runs" / "index.jsonl") + return [row for _, row in sorted(enumerate(rows), + key=lambda item: (*chronology_key(item[1].get("generated_at")), item[0]), reverse=True)] + + +def latest_bound_recommendation(runs: list[dict[str, Any]], agent_id: str) -> dict[str, Any] | None: + # Journal decoding only. The TS owner validates source, actor and task binding. + for row in runs: + resolution = row.get("recommended_action_resolution") + if row.get("agent_id") == agent_id and isinstance(resolution, dict) and resolution.get("step_revision"): + return resolution + return None + + +def lane_recommendation_context(source: dict[str, Any], *, agent_id: str | None, + selected_todo: dict[str, Any] | None, task: dict[str, Any] | None, + prior_resolution: dict[str, Any] | None = None, write: dict[str, Any] | None = None, +) -> dict[str, Any]: + # Reuse the existing frontier's semantic fact vocabulary. Display indexes, + # selection reasons and bounded text decorations are not task revisions. + facts = {key: task[key] for key in (*FRONTIER_REVISION_FIELDS, "updated_at", "completed_at", "goal_acceptance_guard") + if task.get(key) is not None} if task is not None else None + try: + result = effect_runtime_result("work_item.refresh_recommendation.lane", { + **source, "agent_id": agent_id, "selected_todo": selected_todo, + "task_facts": facts, "prior_resolution": prior_resolution, "write": write, + }) + except EffectRuntimeRejected as error: + raise ValueError(str(error)) from error + if not isinstance(result, dict) or not isinstance(result.get("basis"), str): + raise RuntimeError("invalid TypeScript lane recommendation result") + if write is not None and result.get("admitted") is not True: + raise RecommendationWritebackRejected(result) + return result + + +def project_agent_next_actions(goal: dict[str, Any], todo_fields: dict[str, Any], + *, source: dict[str, Any], runs: list[dict[str, Any]], +) -> list[dict[str, Any]]: + from ..agents.agent_lane_recommendation import build_agent_lane_next_action + summary = todo_fields.get("agent_todos") or {} + tasks = {item["todo_id"]: item for item in summary.get("items") or [] if item.get("todo_id")} + routes = [] + for agent in registered_agent_ids_for_goal(goal): + selected = build_agent_lane_next_action( + agent_identity={"agent_id": agent}, agent_todo_summary=summary, + capability_gate=None, active_next_action=[], + ) + if selected: + context = lane_recommendation_context(source, agent_id=agent, + selected_todo=selected, task=tasks.get(selected.get("todo_id")), + prior_resolution=latest_bound_recommendation(runs, agent)) + route = context["selected_todo"] + routes.append({key: route[key] for key in ("agent_id", "todo_id", "text", "next_step", "next_action_basis") if key in route}) + return routes + + +@contextmanager +def recommendation_source_guard( + registry_path: Path, state_path: Path, goal_id: str, + *, source_registry: Path, +) -> Iterator[tuple[dict[str, Any] | None, str]]: + # Registration/configuration use the same registry lock. Quota's outer + # index/source guard precedes this short registry -> state critical section. + # Release before projection/global sync to avoid recursive registry locking. + # Configuration locks source before its shared projection. Keep that order, + # and revalidate the projection's route under both locks before using it. + with ExitStack() as stack: + stack.enter_context(exclusive_cross_runtime_file_lock(source_registry, operation="recommendation-writeback")) + if registry_path.resolve() != source_registry.resolve(): + stack.enter_context(exclusive_cross_runtime_file_lock(registry_path, operation="recommendation-writeback")) + current_source, goal = load_recommendation_source_goal(registry_path, goal_id) + if current_source != source_registry.resolve(): + raise ValueError("Next Action source registry changed; read current status and rejudge before retrying") + stack.enter_context(exclusive_cross_runtime_file_lock(state_path, operation="recommendation-writeback")) + yield goal, state_path.read_text(encoding="utf-8") if state_path.exists() else "" diff --git a/loopx/control_plane/work_items/refresh_recommendation.py b/loopx/control_plane/work_items/refresh_recommendation.py index f7a97bd242..bc8fc4cd89 100644 --- a/loopx/control_plane/work_items/refresh_recommendation.py +++ b/loopx/control_plane/work_items/refresh_recommendation.py @@ -44,8 +44,8 @@ def load_refresh_planning_source( ) -> RefreshPlanningSource: """Read one shared planning snapshot without repairing its display. - Canonical Todo availability permits observation without Markdown, not an - edit of missing Next Action narrative. Provider failures propagate. + Canonical Todos remain available without a Markdown display; steps bind to + that task snapshot. Provider failures propagate rather than using prose. """ events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id)) canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) @@ -136,6 +136,10 @@ def resolve_refresh_recommendation( state_path: Path | None = None, rollout_events: list[dict[str, Any]] | None = None, todo_fields: dict[str, Any] | None = None, + next_step: str | None = None, + next_step_basis: str | None = None, + source_context: dict[str, Any] | None = None, + runs: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Adapt canonical Todo facts into the TS-owned refresh read reducer.""" @@ -144,7 +148,7 @@ def resolve_refresh_recommendation( shared_action: str | None = None summary: dict[str, Any] | None = None lane_candidate: dict[str, Any] | None = None - if not explicit_action: + if not explicit_action or next_step: settlement_todo_id = normalize_todo_id( settlement_identity.get("todo_id") if isinstance(settlement_identity, Mapping) @@ -171,12 +175,18 @@ def resolve_refresh_recommendation( active_next_action=next_action_entries, receipt_bound_todo_id=settlement_todo_id, ) + if next_step and not agent_id: + raise ValueError("--next-action requires a registered --agent-id") + if next_step and explicit_action and next_step != explicit_action: + raise ValueError("--next-action and --recommended-action must agree when supplied together") try: result = effect_runtime_result( "work_item.refresh_recommendation.resolve", { "schema_version": REFRESH_RECOMMENDATION_REQUEST_SCHEMA_VERSION, - "explicit_action": explicit_action, + # A step decorates the selected task; its text must not bypass + # task selection via the unbound explicit recommendation arm. + "explicit_action": None if next_step else explicit_action, "agent_id": agent_id, "settlement_identity": ( dict(settlement_identity) @@ -197,6 +207,22 @@ def resolve_refresh_recommendation( ): raise RuntimeError("TypeScript refresh recommendation shape mismatch") resolved = dict(result) + if source_context is not None and agent_id and summary is not None: + from .recommendation_source_io import lane_recommendation_context, latest_bound_recommendation + task = _exact_todo(summary, resolved.get("todo_id")) + selected = lane_candidate + if resolved.get("recommended_action_source") == RECOMMENDED_ACTION_SOURCE_SETTLEMENT_BOUND_TODO: + selected = task + context = lane_recommendation_context(source_context, agent_id=agent_id, + selected_todo=selected, task=task, + prior_resolution=latest_bound_recommendation(runs or [], agent_id), + write={"text": next_step, "expected_basis": next_step_basis} if next_step else None) + if next_step: + resolved = context["resolution"] + elif isinstance(context.get("selected_todo"), dict) and context["selected_todo"].get("next_step"): + # A derived refresh may reuse the bound step but never mint a new + # step receipt or silently change its original read basis. + resolved = {**resolved, "recommended_action": context["selected_todo"]["next_step"]} validate_local_control_text( "recommended_action", str(resolved.get("recommended_action") or ""), diff --git a/loopx/control_plane/work_items/refresh_recommendation.ts b/loopx/control_plane/work_items/refresh_recommendation.ts index 0ada28c6c9..37ea5439fd 100644 --- a/loopx/control_plane/work_items/refresh_recommendation.ts +++ b/loopx/control_plane/work_items/refresh_recommendation.ts @@ -9,6 +9,9 @@ import { requireJsonObject, requireNonEmptyString, } from "../runtime_decode.ts"; +import {createHash} from "node:crypto"; +import {ENVELOPED_SHA256_PATTERN} from "../content_digest.ts"; +import {requireStringArray} from "../runtime_decode.ts"; export const REFRESH_RECOMMENDATION_REQUEST_SCHEMA_VERSION = "refresh_recommendation_request_v0"; @@ -19,6 +22,7 @@ const TODO_ID_PATTERN = /^todo_[a-z0-9_-]{3,64}$/; type RecommendationSource = | "explicit_arg" + | "agent_lane_step" | "settlement_bound_todo" | "agent_lane_selected_todo" | "active_state_next_action" @@ -44,6 +48,84 @@ interface RecommendationCandidate extends JsonObject { claim_required_before_work?: boolean; } +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, stable(item)])); + } + return value; +} + +function digest(value: unknown): string { + return "sha256:" + createHash("sha256").update(JSON.stringify(stable(value))).digest("hex"); +} + +/** Evolve the existing recommendation receipt, not a second task/route store. + * The caller supplies an already selected candidate and its authoritative task + * facts. A step cannot select work, grant a claim/lease or settle a replan. */ +export function resolveLaneRecommendation(value: unknown): JsonObject { + const request = requireJsonObject(value, "lane recommendation"); + const goalId = requireNonEmptyString(request.goal_id, "goal_id"); + const actor = optionalNonEmptyString(request.agent_id, "agent_id"); + const sourceRevision = requireNonEmptyString(request.source_revision, "source_revision"); + if (!ENVELOPED_SHA256_PATTERN.test(sourceRevision)) { + throw new EffectRuntimeRequestError("source_revision must be a SHA-256 digest, not an intent authority revision"); + } + const agents = [...new Set(requireStringArray(request.registered_agents, "registered_agents"))].sort(); + const rawSelected = request.selected_todo == null ? null : requireJsonObject(request.selected_todo, "selected_todo"); + const selected = candidate(rawSelected, "selected_todo"); + const taskFacts = request.task_facts == null ? null : requireJsonObject(request.task_facts, "task_facts"); + // `done` is a derived display flag: compact hot candidates may omit false. + // Status remains the lifecycle source, so full and compact reads must agree. + const taskBasis = digest(taskFacts === null ? null : {...taskFacts, + done: taskFacts.done === true || taskFacts.status === "done"}); + const prior = request.prior_resolution == null ? null : requireJsonObject(request.prior_resolution, "prior_resolution"); + // Only the actor's latest bound receipt participates. Never search older + // matching tasks: doing so would revive an abandoned experiment. + const priorRevision = prior?.recommended_action_source === "agent_lane_step" && prior.agent_id === actor + ? prior.step_revision : null; + const basis = digest([goalId, sourceRevision, agents, actor, taskBasis, priorRevision]); + const runnable = actor !== null && agents.includes(actor) && selected !== null && + selected.task_class === "advancement_task" && candidateIsRunnable(selected, actor); + const projected: JsonObject | null = rawSelected === null ? null : {...rawSelected, next_action_basis: basis}; + // Output decorations are always rebuilt, even if the input is a prior display. + if (projected) delete projected.next_step; + if (projected && runnable && prior?.schema_version === REFRESH_RECOMMENDATION_SCHEMA_VERSION && + prior.recommended_action_source === "agent_lane_step" && prior.agent_id === actor && + prior.goal_id === goalId && prior.todo_id === selected?.todo_id && + prior.todo_basis === taskBasis && prior.source_revision === sourceRevision && + typeof prior.recommended_action === "string" && prior.recommended_action.length <= 1200) { + projected.next_step = prior.recommended_action; + } + const context: JsonObject = {basis, selected_todo: projected}; + if (request.write == null) return context; + const write = requireJsonObject(request.write, "lane recommendation write"); + const text = requireNonEmptyString(write.text, "next_action").trim(); + if (text.length > 1200) throw new EffectRuntimeRequestError("next_action exceeds 1200 characters"); + const expected = optionalNonEmptyString(write.expected_basis, "next_action_basis"); + if (expected && !ENVELOPED_SHA256_PATTERN.test(expected)) { + throw new EffectRuntimeRequestError("--next-action-basis must be a SHA-256 basis"); + } + const reject = (code: string, error: string): JsonObject => ({...context, + admitted: false, error_code: code, error, reread_required: true}); + if (!actor || !agents.includes(actor)) { + return reject("next_action_actor_unregistered", "Next Action requires a registered --agent-id"); + } + if (expected && expected !== basis) { + return reject("next_action_basis_conflict", "Next Action task or lane read basis changed; reread status and rejudge before retrying"); + } + if (!runnable || !taskFacts || taskFacts.todo_id !== selected?.todo_id) { + return reject("next_action_task_unavailable", "No selected eligible advancement Todo for this agent; update or select work through the existing Todo route"); + } + const resolution: JsonObject = { + ...recommendation(text, "agent_lane_step", "agent_lane", "not_applicable", selected), + goal_id: goalId, agent_id: actor, todo_basis: taskBasis, source_revision: sourceRevision, + read_basis: basis, step_revision: digest([basis, text]), + }; + return {...context, admitted: true, resolution}; +} + export interface RefreshRecommendation extends JsonObject { schema_version: typeof REFRESH_RECOMMENDATION_SCHEMA_VERSION; recommended_action: string; diff --git a/loopx/extensions/lark/presentation/projection_rows.py b/loopx/extensions/lark/presentation/projection_rows.py index 59e6cd2730..808ef65c94 100644 --- a/loopx/extensions/lark/presentation/projection_rows.py +++ b/loopx/extensions/lark/presentation/projection_rows.py @@ -321,7 +321,11 @@ def _projection_row( fallback_text: str, projection_agent_id: str | None = None, ) -> dict[str, Any]: - text = _projection_item_text(item, fallback=fallback_text) + text = ( + _compact_text(item["next_step"], limit=260) + if kind == "next_action" and item.get("next_step") + else _projection_item_text(item, fallback=fallback_text) + ) task_class = normalize_explicit_todo_task_class(item.get("task_class")) or ( TODO_TASK_CLASS_USER_GATE if role == "user" else TODO_TASK_CLASS_ADVANCEMENT ) diff --git a/loopx/presentation/renderers/quota_markdown.py b/loopx/presentation/renderers/quota_markdown.py index 888b86b9dd..4018826844 100644 --- a/loopx/presentation/renderers/quota_markdown.py +++ b/loopx/presentation/renderers/quota_markdown.py @@ -391,6 +391,8 @@ def render_quota_should_run_markdown(payload: dict[str, Any]) -> str: lines.append( f"- agent_lane_next_action_text: {markdown_scalar(agent_lane_next_action.get('text'))}" ) + if agent_lane_next_action.get("next_step"): + lines.append(f"- agent_lane_next_step: {markdown_scalar(agent_lane_next_action['next_step'])}") agent_lane_frontier_hint = as_dict(payload.get("agent_lane_frontier_hint")) if agent_lane_frontier_hint: lines.append( diff --git a/loopx/presentation/renderers/status_markdown.py b/loopx/presentation/renderers/status_markdown.py index 89eb03715d..f596156f73 100644 --- a/loopx/presentation/renderers/status_markdown.py +++ b/loopx/presentation/renderers/status_markdown.py @@ -1091,6 +1091,8 @@ def append_attention_queue_item_header_markdown( f" - agent_next_action: {markdown_scalar(route.get('agent_id') or '')} " f"{markdown_scalar(route.get('todo_id') or '')} {markdown_scalar(route.get('text') or '')}" ) + if route.get("next_step"): + lines.append(f" - next_step: {markdown_scalar(route['next_step'])}") latest_run_action = markdown_scalar(item.get("latest_run_recommended_action") or "") if latest_run_action: lines.append(f" - latest_run_recommended_action: {latest_run_action}") @@ -1163,6 +1165,8 @@ def _append_project_asset_agent_lane_markdown( f"agent={agent} todo_id={todo_id} selected_by={selected_by} " f"confidence={confidence} source=agent_lane_next_action action={action}" ) + if agent_lane_next_action.get("next_step"): + lines.append(f" - next_step: {markdown_scalar(agent_lane_next_action['next_step'])}") if agent_lane_frontier_hint: lines.append( " - agent_lane_frontier_hint: " diff --git a/loopx/quota.py b/loopx/quota.py index 54c2cb23af..d5e1113744 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -798,6 +798,7 @@ def build_quota_plan( "agent_todos", "active_state_next_action", "active_state_next_action_entries", + "recommendation_context", "standing_decision_authority", "long_task_cadence_hint", "stale_latest_run_warning", diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index ac20ed044b..d7a58943e5 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -2095,7 +2095,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 894, + "line": 841, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/state_projection.py b/loopx/state_projection.py index 6411508dfb..f379cf0aa5 100644 --- a/loopx/state_projection.py +++ b/loopx/state_projection.py @@ -196,8 +196,8 @@ def next_action_projection_warning( "explicitly preserving the active-state Next Action" ) warning["recommended_action"] = ( - "continue the agent's Todo route; update shared Next Action through " - "refresh-state with confirmed sole-peer admission or goal scope and a current read basis" + "continue the agent's selected Todo; refresh-state --next-action records " + "a bound task step without replacing shared compatibility prose" ) else: warning["reason"] = ( @@ -205,9 +205,8 @@ def next_action_projection_warning( "Next Action" ) warning["recommended_action"] = ( - "if the latest run action is the intended durable route, write it back " - "explicitly with refresh-state --next-action; otherwise keep treating " - "the run recommendation and active-state Next Action as separate signals" + "read the selected Todo route; only a matching bound recommendation " + "is a current task step. Historical prose is not execution authority" ) lane_value = ( agent_lane_next_action.get("text") diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 65c7177fdb..e5a4ce514e 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -77,8 +77,8 @@ write_shared_runtime_projection, ) from .agent_registry import registered_agent_ids_for_goal -from .control_plane.work_items.next_action_writeback_io import ( - load_next_action_source_goal, next_action_source_guard, next_action_writeback_context, +from .control_plane.work_items.recommendation_source_io import ( + load_recommendation_source_goal, recommendation_source_guard, recommendation_source_context, ) from .control_plane.runtime.runtime_projection_route import ( compact_runtime_projection_route, @@ -86,8 +86,6 @@ ) from .feedback import validate_local_control_text, validate_public_safe_text from .file_lock import exclusive_file_lock -from .control_plane.coordination.runtime_shadow_writer_adapter import require_prose_state_write_allowed -from .control_plane.todos.active_state_editing import atomic_write_state_text from .global_registry import sync_project_registry_to_global from .history import ( load_index, @@ -127,7 +125,6 @@ PROGRESS_SCOPE_CHOICES = (GOAL_PROGRESS_SCOPE, AGENT_LANE_PROGRESS_SCOPE) BULLET_PREFIX_RE = re.compile(r"^(?:[-*]\s+|\d+[.)]\s+)") CHECKBOX_PREFIX_RE = re.compile(r"^\[(?P[ xX])\]\s+") -ACTIVE_STATE_NEXT_ACTION_UPDATE_SCHEMA_VERSION = "active_state_next_action_update_v0" REPAIR_NOOP_SCHEMA_VERSION = "repair_noop_v0" @@ -212,56 +209,6 @@ def normalize_progress_scope(value: str | None) -> str: return normalized -def next_action_section_bounds(lines: list[str]) -> tuple[int, int] | None: - for index, line in enumerate(lines): - if line.strip() != "## Next Action": - continue - end = len(lines) - for next_index in range(index + 1, len(lines)): - if lines[next_index].startswith("## "): - end = next_index - break - return index, end - return None - - -def next_action_insert_anchor(lines: list[str]) -> int: - preferred = { - "## Recent User Feedback", - "## Progress Ledger", - "## Operating Lessons", - "## Completed Work Archive", - } - for index, line in enumerate(lines): - if line.strip() in preferred: - return index - return len(lines) - - -def replace_next_action_section( - state_text: str, - *, - next_action: str, - updated_at: str, -) -> tuple[str, bool]: - lines = state_text.splitlines() - section = ["## Next Action", "", f"- {next_action}", ""] - bounds = next_action_section_bounds(lines) - if bounds: - start, end = bounds - updated_lines = [*lines[:start], *section, *lines[end:]] - else: - anchor = next_action_insert_anchor(lines) - insert = list(section) - if anchor > 0 and lines[anchor - 1].strip(): - insert.insert(0, "") - updated_lines = [*lines[:anchor], *insert, *lines[anchor:]] - section_text = "\n".join(updated_lines).rstrip() + "\n" - if section_text.rstrip("\n") == state_text.rstrip("\n"): - return state_text, False - return replace_updated_at(section_text, updated_at), True - - def clean_action_line(line: str) -> str: text = BULLET_PREFIX_RE.sub("", line.strip()).strip() return CHECKBOX_PREFIX_RE.sub("", text).strip() @@ -566,7 +513,7 @@ def _build_state_refresh_output_projections( field: record.get(field) for field in ( "agent_vision", "vision_checkpoint", "recommended_action", - "recommended_action_source", "active_state_next_action_update", + "recommended_action_source", "generated_at", "health_check", ) }) @@ -1006,7 +953,7 @@ def refresh_state_run( next_action_source_registry = registry_path.resolve() state_registry = registry if next_action: - next_action_source_registry, source_goal = load_next_action_source_goal(registry_path, safe_goal_id) + next_action_source_registry, source_goal = load_recommendation_source_goal(registry_path, safe_goal_id) state_registry = {**registry, "goals": [source_goal] if source_goal is not None else []} registry_goal, resolved_project, resolved_state_file = resolve_goal_state( registry=state_registry, @@ -1015,7 +962,7 @@ def refresh_state_run( state_file_override=state_file, ) planning_source = load_refresh_planning_source( - runtime_root, safe_goal_id, resolved_state_file, require_display=bool(next_action) + runtime_root, safe_goal_id, resolved_state_file, require_display=False ) state_text, planning_events, todo_fields = ( planning_source.state_text, planning_source.events, planning_source.todo_fields, @@ -1093,39 +1040,17 @@ def refresh_state_run( require_path_delta_for_durable_change=autonomous_replan_recorded, ) generated_at = now_local() - active_state_next_action_update: dict[str, Any] | None = None - if normalized_next_action: - source_basis = next_action_writeback_context(registry_goal, state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry)["basis"] - with next_action_source_guard(registry_path, resolved_state_file, safe_goal_id, source_registry=next_action_source_registry) as (current_goal, locked_state_text): - admission = next_action_writeback_context(current_goal, locked_state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry, write={ - "agent_id": normalized_agent_id or None, - "progress_scope": normalized_progress_scope, - "expected_basis": next_action_basis, - "source_basis": source_basis, - }) - expected_write_state_text = locked_state_text - updated_state_text, state_updated = replace_next_action_section( - locked_state_text, - next_action=normalized_next_action, - updated_at=generated_at, - ) - active_state_next_action_update = { - "schema_version": ACTIVE_STATE_NEXT_ACTION_UPDATE_SCHEMA_VERSION, - "source": "refresh_state", - "next_action": normalized_next_action, - "updated": bool(state_updated and not dry_run), - "would_update": bool(state_updated), - "dry_run": bool(dry_run), - "updated_at": generated_at if state_updated else None, - "read_basis": admission["basis"], - "agent_id": normalized_agent_id or None, - } - state_text = updated_state_text if state_updated else locked_state_text - + # Next Action is now an actor/Todo-bound recommendation in the existing + # run journal. It never rewrites narrative or a Todo projection. + recommendation_context = recommendation_source_context( + registry_goal, state_text, source_registry=next_action_source_registry, todo_fields=todo_fields + ) if registry_goal is not None else None recommendation_resolution = resolve_refresh_recommendation( state_text, todo_fields=todo_fields, explicit_action=recommended_action, + next_step=normalized_next_action, next_step_basis=next_action_basis, + source_context=recommendation_context, runs=newest_first_runs, agent_id=normalized_agent_id or None, settlement_identity=( settlement_identity.as_dict() if settlement_identity is not None else None @@ -1151,7 +1076,7 @@ def refresh_state_run( todo_fields=todo_fields, autonomous_replan_recorded=autonomous_replan_recorded, requested_delta_kinds=normalized_repair_delta_kinds, - active_state_next_action_update=active_state_next_action_update, + active_state_next_action_update=None, agent_vision=agent_vision, existing_agent_vision=existing_agent_vision, agent_id=normalized_agent_id, @@ -1245,7 +1170,7 @@ def refresh_state_run( existing_agent_vision=existing_agent_vision, vision_unchanged_reason=vision_unchanged_reason, delivery_outcome=normalized_delivery_outcome, - active_state_next_action_update=active_state_next_action_update, + active_state_next_action_update=None, delivery_boundary=normalized_delivery_boundary, todo_id=(settlement_identity.todo_id if settlement_identity else None), completion_todo_id=completion_todo_id, @@ -1314,27 +1239,6 @@ def refresh_state_run( "progress_observation" ) classification = prior_writeback_run["classification"] - if ( - active_state_next_action_update - and active_state_next_action_update.get("would_update") - and not dry_run - ): - with next_action_source_guard(registry_path, resolved_state_file, safe_goal_id, source_registry=next_action_source_registry) as (current_goal, current_state_text): - # Recheck complete membership and the snapshot captured before - # semantic qualification, even for a sole actor with no flag. - next_action_writeback_context(current_goal, current_state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry, write={ - "agent_id": normalized_agent_id or None, - "progress_scope": normalized_progress_scope, - "expected_basis": active_state_next_action_update["read_basis"], - }) - require_prose_state_write_allowed( - registry_path=registry_path, runtime_root=runtime_root, - goal_id=safe_goal_id, state_path=resolved_state_file, - original_text=current_state_text, planned_text=state_text, - ) - applied_basis = next_action_writeback_context(current_goal, state_text, goal_id=safe_goal_id, source_registry=next_action_source_registry)["basis"] - atomic_write_state_text(resolved_state_file, state_text) - active_state_next_action_update["applied_basis"] = applied_basis record = build_state_refresh_record( goal_id=safe_goal_id, state_file=resolved_state_file, @@ -1403,8 +1307,6 @@ def refresh_state_run( record["autonomous_replan_ack"]["semantic_delta"] = ( replan_semantic_delta ) - if active_state_next_action_update: - record["active_state_next_action_update"] = active_state_next_action_update compact_route = compact_runtime_projection_route(runtime_projection_route) compact_route["projection_enabled"] = bool(sync_global) compact_route["projection_marker_field"] = "shared_runtime_projection" @@ -1433,6 +1335,23 @@ def refresh_state_run( # spans ledger-basis read + row append so concurrent refreshes cannot fund # two deltas from one stale basis; the appended row advances the basis. with ExitStack() as usage_booking_guard: + if normalized_next_action: + current_goal, current_text = usage_booking_guard.enter_context( + recommendation_source_guard(registry_path, resolved_state_file, safe_goal_id, + source_registry=next_action_source_registry)) + if current_goal is None: + raise ValueError("Next Action source Goal disappeared; reread status") + current_planning = load_refresh_planning_source( + runtime_root, safe_goal_id, resolved_state_file, require_display=False) + resolve_refresh_recommendation(current_text, + todo_fields=current_planning.todo_fields, agent_id=normalized_agent_id or None, + settlement_identity=settlement_identity.as_dict() if settlement_identity else None, + registry_goal=current_goal, state_path=resolved_state_file, + rollout_events=current_planning.events, next_step=normalized_next_action, + next_step_basis=recommendation_resolution["read_basis"], + source_context=recommendation_source_context(current_goal, current_text, + source_registry=next_action_source_registry, todo_fields=current_planning.todo_fields), + runs=newest_first_runs) if checkpoint_supplement: assert settlement_identity is not None context = usage_booking_guard.enter_context(checkpoint_commit_guard( @@ -1466,18 +1385,11 @@ def refresh_state_run( payload["usage"] = dict(record["usage"]) if dry_run: expected_write_scopes = ["runtime_history"] - if active_state_next_action_update and active_state_next_action_update.get("would_update"): - expected_write_scopes.insert(0, "active_state") if sync_global and route_status in {"resolved", "single_runtime"}: expected_write_scopes.append("global_registry") if shared_runtime_root: expected_write_scopes.append("shared_runtime_projection") patch_parts = [f"append refresh-state run classification={classification}"] - if active_state_next_action_update: - if active_state_next_action_update.get("would_update"): - patch_parts.append("preview active-state Next Action update") - else: - patch_parts.append("preserve active-state Next Action") if sync_global and route_status in {"resolved", "single_runtime"}: patch_parts.append("sync public-safe registry projection") elif sync_global: diff --git a/loopx/status.py b/loopx/status.py index 5629e81d1b..4188f35331 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -223,7 +223,6 @@ MAX_STATUS_TODOS_PER_ROLE as _TODO_SUMMARY_MAX_STATUS_TODOS_PER_ROLE, MAX_TODO_VISIBILITY_LANE_ITEMS as _TODO_SUMMARY_MAX_TODO_VISIBILITY_LANE_ITEMS, active_state_todo_attention_item as _active_state_todo_attention_item_read_model, - active_next_action_todo_ids, attach_dependency_blockers, compact_todo_group as compact_todo_group, compact_todo_item as compact_todo_item, @@ -725,7 +724,6 @@ def active_state_todo_fields( **({"todo_snapshot": todo_snapshot} if todo_snapshot is not None else {}), resolve_goal_local_path=resolve_goal_local_path, active_state_next_action_entries=active_state_next_action_entries, - active_next_action_todo_ids=active_next_action_todo_ids, load_rollout_events=load_rollout_events, rollout_event_log_path=rollout_event_log_path, max_todo_index_rollout_events_per_goal=MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL, From 86e3339fb5974e2c8c9bb326ac2fe68ab7a5c3a5 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 23:26:02 +0800 Subject: [PATCH 5/8] test(state): cover bound-step continuity and retire prose overwrite expectations Signed-off-by: huangruiteng --- .../refresh-state-agent-lane-scope-smoke.py | 80 ++-- .../goal-vision-refresh-state-budget-smoke.py | 19 +- .../next-action-projection-contract-smoke.py | 414 +++--------------- .../test_next_action_writeback.py | 324 +++++++------- .../test_refresh_checkpoint_isolation.py | 3 +- .../test_refresh_checkpoint_recovery.py | 4 +- .../test_shadow_observable_e2e.py | 7 +- .../test_shadow_writer_boundaries.py | 112 ++--- .../content_digest_single_owner.test.ts | 1 + .../next_action_writeback.test.ts | 48 -- .../refresh_recommendation.test.ts | 55 +++ .../control_plane_ts/todo_next_action.test.ts | 12 + 12 files changed, 376 insertions(+), 703 deletions(-) delete mode 100644 tests/control_plane_ts/next_action_writeback.test.ts diff --git a/examples/control_plane/refresh-state-agent-lane-scope-smoke.py b/examples/control_plane/refresh-state-agent-lane-scope-smoke.py index 02d6458782..d3b6c96c67 100644 --- a/examples/control_plane/refresh-state-agent-lane-scope-smoke.py +++ b/examples/control_plane/refresh-state-agent-lane-scope-smoke.py @@ -14,8 +14,6 @@ import loopx.state_refresh as state_refresh from loopx.history import collect_history from loopx.status import collect_status -from loopx.agent_registry import load_goal_from_registry -from loopx.control_plane.work_items.next_action_writeback_io import next_action_writeback_context GOAL_ID = "refresh-state-agent-lane-goal" @@ -223,24 +221,24 @@ def main() -> None: ), ) - expect_value_error( - "agent-lane refresh-state cannot update shared Next Action", - lambda: state_refresh.refresh_state_run( - registry_path=registry_path, - runtime_root_override=str(runtime), - goal_id=GOAL_ID, - project=project, - state_file=None, - classification="frontstage_side_lane_next_action_write", - recommended_action=SIDE_ACTION, - next_action=SIDE_ACTION, - delivery_batch_scale="single_surface", - delivery_outcome="outcome_progress", - agent_id="codex-side-bypass", - dry_run=True, - sync_global=False, - ), + step_preview = state_refresh.refresh_state_run( + registry_path=registry_path, + runtime_root_override=str(runtime), + goal_id=GOAL_ID, + project=project, + state_file=None, + classification="frontstage_side_lane_next_action_write", + recommended_action=SIDE_ACTION, + next_action=SIDE_ACTION, + delivery_batch_scale="single_surface", + delivery_outcome="outcome_progress", + agent_id="codex-side-bypass", + dry_run=True, + sync_global=False, ) + assert step_preview["progress_scope"] == "agent_lane" + assert step_preview["recommended_action_resolution"]["recommended_action_source"] == "agent_lane_step" + peer_goal_scope = state_refresh.refresh_state_run( registry_path=registry_path, @@ -381,24 +379,24 @@ def main() -> None: ), ) - expect_value_error( - "agent-lane refresh-state cannot update shared Next Action", - lambda: state_refresh.refresh_state_run( - registry_path=registry_path, - runtime_root_override=str(runtime), - goal_id=GOAL_ID, - project=project, - state_file=None, - classification="adapter_lifecycle_primary_default_lane_next", - recommended_action=PRIMARY_AGENT_LANE_ACTION, - next_action=PRIMARY_AGENT_LANE_ACTION, - delivery_batch_scale="single_surface", - delivery_outcome="outcome_progress", - agent_id="codex-main-control", - dry_run=True, - sync_global=False, - ), + step_preview = state_refresh.refresh_state_run( + registry_path=registry_path, + runtime_root_override=str(runtime), + goal_id=GOAL_ID, + project=project, + state_file=None, + classification="adapter_lifecycle_primary_default_lane_next", + recommended_action=PRIMARY_AGENT_LANE_ACTION, + next_action=PRIMARY_AGENT_LANE_ACTION, + delivery_batch_scale="single_surface", + delivery_outcome="outcome_progress", + agent_id="codex-main-control", + dry_run=True, + sync_global=False, ) + assert step_preview["progress_scope"] == "agent_lane" + assert step_preview["recommended_action_resolution"]["recommended_action_source"] == "agent_lane_step" + state_refresh.now_local = lambda: "2026-06-20T00:04:00+00:00" primary_next_payload = state_refresh.refresh_state_run( @@ -411,10 +409,6 @@ def main() -> None: recommended_action=PRIMARY_AGENT_LANE_ACTION, next_action=PRIMARY_AGENT_LANE_ACTION, delivery_batch_scale="single_surface", - next_action_basis=next_action_writeback_context( - load_goal_from_registry(registry_path, GOAL_ID), state_path.read_text(encoding="utf-8"), - source_registry=registry_path, - )["basis"], delivery_outcome="outcome_progress", agent_id="codex-main-control", progress_scope="goal", @@ -424,7 +418,7 @@ def main() -> None: assert primary_next_payload["progress_scope"] == "goal", primary_next_payload assert primary_next_payload["agent_id"] == "codex-main-control", primary_next_payload assert primary_next_payload.get("agent_lane") is None, primary_next_payload - assert primary_next_payload["active_state_next_action_update"]["updated"] is True + assert primary_next_payload["recommended_action_resolution"]["recommended_action_source"] == "agent_lane_step" primary_goal_status = collect_status( registry_path=registry_path, @@ -439,7 +433,7 @@ def main() -> None: ) assert primary_goal_item["status"] == "adapter_lifecycle_primary_goal_next" assert primary_goal_item["recommended_action"] == PRIMARY_AGENT_LANE_ACTION - assert primary_goal_item["active_state_next_action"] == PRIMARY_AGENT_LANE_ACTION + assert primary_goal_item["active_state_next_action"] == PRIMARY_ACTION assert ( primary_goal_item["latest_run_recommended_action"] == PRIMARY_AGENT_LANE_ACTION @@ -448,7 +442,7 @@ def main() -> None: primary_goal_item["latest_run_recommended_action_source"] == "latest_status_run" ), primary_goal_item - assert "next_action_projection_warning" not in primary_goal_item, primary_goal_item + finally: state_refresh.now_local = original_now_local state_refresh.capture_delivery_workspace = original_capture_delivery_workspace diff --git a/examples/project/goal-vision-refresh-state-budget-smoke.py b/examples/project/goal-vision-refresh-state-budget-smoke.py index 9f80d52a75..5a3af07ffe 100644 --- a/examples/project/goal-vision-refresh-state-budget-smoke.py +++ b/examples/project/goal-vision-refresh-state-budget-smoke.py @@ -513,12 +513,6 @@ def main() -> int: registry_path, runtime, inline_vision_args=["--vision-unchanged-reason", "x" * 241], - extra_args=[ - "--next-action", - "This rejected refresh must not replace the durable action.", - "--progress-scope", - "goal", - ], dry_run=False, check=False, ) @@ -609,6 +603,10 @@ def main() -> int: assert "limit is 80" in long_todo["error"], long_todo assert "suggested compact value" in long_todo["error"], long_todo + from loopx.todos import add_goal_todo + add_goal_todo(registry_path=registry_path, goal_id=GOAL_ID, role="agent", + text="Validate the scoped delivery evidence.", task_class="advancement_task", + claimed_by=AGENT_ID) local_next_action = "/Users/example/private/raw-task-note" private_next_action_result = payload( run_cli( @@ -624,12 +622,11 @@ def main() -> int: ) ) assert private_next_action_result["ok"] is True, private_next_action_result - assert private_next_action_result["active_state_next_action_update"][ - "next_action" + assert private_next_action_result["recommended_action_resolution"][ + "recommended_action" ] == local_next_action, private_next_action_result - assert private_next_action_result["active_state_next_action_update"][ - "would_update" - ] is True, private_next_action_result + assert private_next_action_result["recommended_action_source"] == "agent_lane_step" + assert "active_state_next_action_update" not in private_next_action_result secret_next_action_result = run_cli( registry_path, diff --git a/examples/project/next-action-projection-contract-smoke.py b/examples/project/next-action-projection-contract-smoke.py index b3468415e1..6a2285ff36 100644 --- a/examples/project/next-action-projection-contract-smoke.py +++ b/examples/project/next-action-projection-contract-smoke.py @@ -1,39 +1,21 @@ #!/usr/bin/env python3 -"""Smoke-test explicit durable Next Action writeback and projection drift.""" - +"""A bound task step survives history compaction without becoming task authority.""" from __future__ import annotations import json +from pathlib import Path import subprocess import sys import tempfile -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) - -import loopx.state_refresh as state_refresh -from loopx.control_plane.scheduler.execution_context import ( - GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, -) -from loopx.presentation.renderers.status_markdown import render_status_markdown -from loopx.quota import build_quota_should_run, render_quota_should_run_markdown -from loopx.state_projection import ( - actions_are_projection_aligned, - next_action_resolution_trace, - state_action_projection_warning, -) +from loopx.control_plane.scheduler.execution_context import GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT +from loopx.quota import build_quota_should_run +from loopx.state_refresh import refresh_state_run from loopx.status import collect_status - GOAL_ID = "next-action-projection-goal" -ACTIVE_NEXT_ACTION = "Keep the durable route on the broad public PoC lane." -PRIMARY_AGENT_ACTION = "Validate the primary public PoC control-plane lane." -RUN_RECOMMENDATION = "Inspect the vliw suite result before changing the durable route." -UPDATED_NEXT_ACTION = "Promote the vliw repair slice as the durable next action." -UPDATED_RUN_RECOMMENDATION = "Validate the vliw repair slice and then write compact evidence." -SIDE_AGENT_ACTION = "Polish the hosted frontstage public case card." -SIDE_AGENT_RUN_RECOMMENDATION = "Continue the hosted frontstage public case card." - +ACTIVE_NEXT_ACTION = "Shared compatibility guidance." +SIDE_AGENT_ACTION = "Evaluate the current artifact." def write_fixture(root: Path, *, include_next_action: bool = True) -> tuple[Path, Path, Path, Path]: project = root / "project" @@ -110,336 +92,62 @@ def assert_state_next_action(path: Path, expected: str) -> None: assert f"- {expected}" in text, text -def collect_projection(registry_path: Path, runtime: Path, project: Path) -> dict[str, object]: - status = collect_status( - registry_path=registry_path, - runtime_root_override=str(runtime), - scan_roots=[project], - limit=5, - ) - items = status["attention_queue"]["items"] - item = next(item for item in items if item["goal_id"] == GOAL_ID) - decision = build_quota_should_run( - status, - goal_id=GOAL_ID, - agent_id="codex-side-bypass", - scheduler_execution_context=( - GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT - ), - ) - return {"status": status, "item": item, "decision": decision} - - -def run_cli_json(args: list[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - "-c", - "from loopx.cli import main; raise SystemExit(main())", - *args, - ], - cwd=Path(__file__).resolve().parents[2], - text=True, - capture_output=True, - check=False, - ) - - -def assert_state_action_projection_warning_read_model() -> None: - contract = {"lane": "advancement_task", "reason_codes": ["open_agent_todo"]} - warning = state_action_projection_warning( - {"active_state_next_action": ACTIVE_NEXT_ACTION}, - agent_todo_summary=None, - selected_action=SIDE_AGENT_ACTION, - work_lane_contract=contract, - ) - assert warning is not None, warning - assert warning["schema_version"] == "state_action_projection_warning_v0", warning - assert warning["requires_state_writeback"] is True, warning - assert warning["active_state_next_action"] == ACTIVE_NEXT_ACTION, warning - assert warning["selected_recommended_action"] == SIDE_AGENT_ACTION, warning - - assert actions_are_projection_aligned( - "[P1] Agent: Validate the primary public PoC control-plane lane.", - "Validate the primary public PoC control-plane lane.", - ) - assert actions_are_projection_aligned( - "todo_fe0c5551dd89: P2: Route quota execution through interaction_contract.agent_channel.primary_action", - "P2: Route quota execution through interaction_contract.agent_channel.primary_action", - ) - assert ( - state_action_projection_warning( - {"active_state_next_action": ACTIVE_NEXT_ACTION}, - agent_todo_summary={ - "claim_scope": {"agent_id": "codex-side-bypass"}, - "first_executable_items": [{"claimed_by": "codex-side-bypass"}], - }, - selected_action=SIDE_AGENT_ACTION, - work_lane_contract=contract, - ) - is None - ) - assert ( - state_action_projection_warning( - {"active_state_next_action": ACTIVE_NEXT_ACTION}, - agent_todo_summary=None, - selected_action=SIDE_AGENT_ACTION, - work_lane_contract={"lane": "continuous_monitor", "reason_codes": ["open_agent_todo"]}, - ) - is None - ) - - trace = next_action_resolution_trace( - primary_action=SIDE_AGENT_ACTION, - mode="bounded_delivery", - active_state_next_action=ACTIVE_NEXT_ACTION, - latest_run_recommended_action=RUN_RECOMMENDATION, - selected_recommended_action=SIDE_AGENT_ACTION, - agent_lane_next_action={"text": SIDE_AGENT_ACTION}, - ) - assert trace is not None, trace - assert trace["summary"] == "source=agent_lane drift=true", trace - def main() -> None: - assert_state_action_projection_warning_read_model() - original_now_local = state_refresh.now_local - try: - with tempfile.TemporaryDirectory(prefix="loopx-next-action-projection-") as raw_tmp: - registry_path, runtime, project, state_path = write_fixture(Path(raw_tmp)) - - state_refresh.now_local = lambda: "2026-06-22T00:01:00+00:00" - implicit_payload = state_refresh.refresh_state_run( - registry_path=registry_path, - runtime_root_override=str(runtime), - goal_id=GOAL_ID, - project=project, - state_file=None, - classification="state_refreshed", - recommended_action=None, - agent_id="codex-main-control", - progress_scope="goal", - dry_run=True, - sync_global=False, - ) - assert ( - implicit_payload["recommended_action"] - == f"[P0] {PRIMARY_AGENT_ACTION}" - ), implicit_payload - assert ( - implicit_payload["recommended_action_source"] - == "agent_lane_selected_todo" - ), implicit_payload - assert implicit_payload.get("active_state_next_action_update") is None, implicit_payload - - default_payload = state_refresh.refresh_state_run( - registry_path=registry_path, - runtime_root_override=str(runtime), - goal_id=GOAL_ID, - project=project, - state_file=None, - classification="state_refreshed", - recommended_action=RUN_RECOMMENDATION, - agent_id="codex-main-control", - progress_scope="goal", - dry_run=False, - sync_global=False, - ) - assert default_payload["recommended_action"] == RUN_RECOMMENDATION, default_payload - assert default_payload["recommended_action_source"] == "explicit_arg", default_payload - assert default_payload.get("active_state_next_action_update") is None, default_payload - assert_state_next_action(state_path, ACTIVE_NEXT_ACTION) - assert RUN_RECOMMENDATION not in state_text(state_path), state_text(state_path) - - state_refresh.now_local = lambda: "2026-06-22T00:01:30+00:00" - lane_payload = state_refresh.refresh_state_run( - registry_path=registry_path, - runtime_root_override=str(runtime), - goal_id=GOAL_ID, - project=project, - state_file=None, - classification="agent_lane_progress", - recommended_action=SIDE_AGENT_RUN_RECOMMENDATION, - agent_id="codex-side-bypass", - progress_scope="agent_lane", - dry_run=False, - sync_global=False, - ) - assert lane_payload["recommended_action"] == SIDE_AGENT_RUN_RECOMMENDATION, lane_payload - - first_projection = collect_projection(registry_path, runtime, project) - first_item = first_projection["item"] - first_decision = first_projection["decision"] - assert first_item["active_state_next_action"] == ACTIVE_NEXT_ACTION, first_item - assert first_item["latest_run_recommended_action"] == RUN_RECOMMENDATION, first_item - assert first_item["next_action_projection_warning"]["requires_state_writeback"] is True, first_item - assert first_decision["active_state_next_action"] == ACTIVE_NEXT_ACTION, first_decision - assert ( - first_decision["latest_run_recommended_action"] - == SIDE_AGENT_RUN_RECOMMENDATION - ), first_decision - first_agent_channel = first_decision["interaction_contract"]["agent_channel"] - assert SIDE_AGENT_ACTION in first_agent_channel["primary_action"], first_decision - first_trace = first_agent_channel["resolution_trace"] - assert first_trace["summary"] == "source=agent_lane drift=true", first_decision - first_warning = first_decision["next_action_projection_warning"] - assert first_warning["severity"] == "info", first_decision - assert first_warning["requires_state_writeback"] is False, first_decision - assert first_warning["agent_lane_next_action"] == "[P1] Polish the hosted frontstage public case card.", first_decision - - state_refresh.now_local = lambda: "2026-06-22T00:02:00+00:00" - explicit_payload = state_refresh.refresh_state_run( - registry_path=registry_path, - runtime_root_override=str(runtime), - goal_id=GOAL_ID, - project=project, - state_file=None, - classification="state_refreshed", - recommended_action=UPDATED_RUN_RECOMMENDATION, - next_action=UPDATED_NEXT_ACTION, - next_action_basis=first_item["next_action_basis"], - agent_id="codex-main-control", - progress_scope="goal", - dry_run=False, - sync_global=False, - ) - update = explicit_payload["active_state_next_action_update"] - assert explicit_payload["recommended_action_source"] == "explicit_arg", explicit_payload - assert update["updated"] is True, explicit_payload - assert update["next_action"] == UPDATED_NEXT_ACTION, explicit_payload - assert_state_next_action(state_path, UPDATED_NEXT_ACTION) - assert ACTIVE_NEXT_ACTION not in state_text(state_path), state_text(state_path) - - second_projection = collect_projection(registry_path, runtime, project) - second_status = second_projection["status"] - second_item = second_projection["item"] - second_decision = second_projection["decision"] - status_markdown = render_status_markdown(second_status) - quota_markdown = render_quota_should_run_markdown(second_decision) - - assert second_item["active_state_next_action"] == UPDATED_NEXT_ACTION, second_item - assert second_item["latest_run_recommended_action"] == UPDATED_RUN_RECOMMENDATION, second_item - assert second_decision["active_state_next_action"] == UPDATED_NEXT_ACTION, second_decision - assert ( - second_decision["latest_run_recommended_action"] - == SIDE_AGENT_RUN_RECOMMENDATION - ), second_decision - second_agent_channel = second_decision["interaction_contract"]["agent_channel"] - assert SIDE_AGENT_ACTION in second_agent_channel["primary_action"], second_decision - second_trace = second_agent_channel["resolution_trace"] - assert second_trace["summary"] == "source=agent_lane drift=true", second_decision - lane = second_decision["agent_lane_next_action"] - assert lane["todo_id"] == "todo_side", second_decision - assert lane["title"] == SIDE_AGENT_ACTION, second_decision - assert SIDE_AGENT_ACTION in lane["text"], second_decision - warning = second_decision["next_action_projection_warning"] - assert warning["severity"] == "info", second_decision - assert warning["requires_state_writeback"] is False, second_decision - assert ( - warning["reason"] - == "current agent lane action differs from the durable goal route while explicitly preserving the active-state Next Action" - ), second_decision - assert ( - warning["recommended_action"] - == "continue the agent's Todo route; update shared Next Action through refresh-state with confirmed sole-peer admission or goal scope and a current read basis" - ), second_decision - assert ( - warning["agent_lane_next_action"] == lane["text"] - ), second_decision - assert "active_state_next_action" in status_markdown, status_markdown - assert "latest_run_recommended_action" in status_markdown, status_markdown - assert "active_state_next_action" in quota_markdown, quota_markdown - assert "latest_run_recommended_action" in quota_markdown, quota_markdown - assert "interaction_agent_action" in quota_markdown, quota_markdown - assert "interaction_agent_resolution" in quota_markdown, quota_markdown - - cli_ok = run_cli_json( - [ - "--format", - "json", - "--registry", - str(registry_path), - "--runtime-root", - str(runtime), - "refresh-state", - "--goal-id", - GOAL_ID, - "--project", - str(project), - "--classification", - "cli_goal_scope_dry_run", - "--recommended-action", - UPDATED_RUN_RECOMMENDATION, - "--agent-id", - "codex-main-control", - "--progress-scope", - "goal", - "--dry-run", - "--no-global-sync", - ] - ) - assert cli_ok.returncode == 0, cli_ok.stderr or cli_ok.stdout - cli_ok_payload = json.loads(cli_ok.stdout) - assert cli_ok_payload["ok"] is True, cli_ok_payload - assert cli_ok_payload["progress_scope"] == "goal", cli_ok_payload - assert cli_ok_payload["agent_id"] == "codex-main-control", cli_ok_payload - - cli_fail = run_cli_json( - [ - "--format", - "json", - "--registry", - str(registry_path), - "--runtime-root", - str(runtime), - "refresh-state", - "--goal-id", - GOAL_ID, - "--project", - str(project), - "--classification", - "cli_unscoped_dry_run", - "--recommended-action", - SIDE_AGENT_ACTION, - "--dry-run", - "--no-global-sync", - ] - ) - assert cli_fail.returncode == 1, cli_fail.stdout - cli_fail_payload = json.loads(cli_fail.stdout) - assert cli_fail_payload["ok"] is False, cli_fail_payload - assert "requires --agent-id" in cli_fail_payload["error"], cli_fail_payload - - with tempfile.TemporaryDirectory(prefix="loopx-next-action-fallback-") as raw_tmp: - registry_path, runtime, project, _state_path = write_fixture( - Path(raw_tmp), - include_next_action=False, - ) - - fallback_payload = state_refresh.refresh_state_run( - registry_path=registry_path, - runtime_root_override=str(runtime), - goal_id=GOAL_ID, - project=project, - state_file=None, - classification="state_refreshed", - recommended_action=None, - agent_id="codex-main-control", - progress_scope="goal", - dry_run=True, - sync_global=False, - ) - assert ( - fallback_payload["recommended_action"] - == f"[P0] {PRIMARY_AGENT_ACTION}" - ), fallback_payload - assert ( - fallback_payload["recommended_action_source"] - == "agent_lane_selected_todo" - ), fallback_payload - finally: - state_refresh.now_local = original_now_local - + with tempfile.TemporaryDirectory(prefix="loopx-bound-recommendation-") as directory: + registry, runtime, project, state = write_fixture(Path(directory)) + before = state.read_bytes() + common = dict(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, project=project, state_file=None, classification="state_refreshed", + recommended_action=None, dry_run=False, sync_global=False) + step = "Evaluate a smaller experiment, retaining the incumbent." + result = refresh_state_run(**common, agent_id="codex-side-bypass", next_action=step) + assert result["recommended_action_resolution"]["todo_id"] == "todo_side" + assert state.read_bytes() == before + # Later observation-only runs must not evict the actor's current bound + # receipt from the semantic history retained outside the recent window. + index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + with index.open("a") as stream: + for number in range(80): + stream.write(json.dumps({"goal_id": GOAL_ID, "agent_id": "codex-side-bypass", + "generated_at": f"2090-01-01T00:00:{number % 60:02d}+00:00", + "classification": "quota_slot_spent"}) + "\n") + status = collect_status(registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[project], limit=2) + decision = build_quota_should_run(status, goal_id=GOAL_ID, agent_id="codex-side-bypass", + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT) + assert decision["should_run"] is True + lane = decision["agent_lane_next_action"] + assert lane["todo_id"] == "todo_side" and lane["next_step"] == step + assert lane["text"] == "[P1] " + SIDE_AGENT_ACTION + assert decision["recommended_action"] == step + assert step in decision["interaction_contract"]["agent_channel"]["primary_action"] + from loopx.extensions.lark.presentation.projection_rows import projection_rows_from_payload + _, rows, warnings = projection_rows_from_payload(decision, + goal_id=GOAL_ID, agent_id="codex-side-bypass", source_id="quota", + include_done=False, limit=100) + assert not warnings + assert any(row["text"] == step and row["original_todo_id"] == "todo_side" for row in rows) + from loopx.presentation.renderers.quota_markdown import render_quota_should_run_markdown + assert "agent_lane_next_step: " + step in render_quota_should_run_markdown(decision) + command = [sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", "status", + "--goal-id", GOAL_ID, "--agent-id", "codex-side-bypass"] + readback = subprocess.run(command, capture_output=True, text=True, timeout=30) + assert readback.returncode == 0, readback.stdout + readback.stderr + item = next(item for item in json.loads(readback.stdout)["attention_queue"]["items"] if item["goal_id"] == GOAL_ID) + projected = item.get("agent_lane_next_action") or item["project_asset"]["agent_lane_next_action"] + assert projected["next_step"] == step + assert item["next_action_basis"] == lane["next_action_basis"] + markdown = subprocess.run([*command[:command.index("--format")], "--format", "markdown", + *command[command.index("--format") + 2:]], capture_output=True, text=True, timeout=30) + assert markdown.returncode == 0 and "next_step: " + step in markdown.stdout + # A different selected task does not inherit the old experiment. + state.write_text(state.read_text().replace("todo_side status=open", "todo_side status=done")) + changed = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[project], limit=2) + next_decision = build_quota_should_run(changed, goal_id=GOAL_ID, agent_id="codex-side-bypass", + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT) + assert step not in next_decision["interaction_contract"]["agent_channel"]["primary_action"] print("next-action-projection-contract-smoke ok") diff --git a/tests/control_plane/test_next_action_writeback.py b/tests/control_plane/test_next_action_writeback.py index 1516370916..17783692bc 100644 --- a/tests/control_plane/test_next_action_writeback.py +++ b/tests/control_plane/test_next_action_writeback.py @@ -1,4 +1,4 @@ -"""Real refresh/status transactions on disposable Goal state.""" +"""Real recommendation writeback, source readback and concurrent CLI transactions.""" from concurrent.futures import ThreadPoolExecutor import json import subprocess @@ -7,11 +7,10 @@ import pytest import loopx.state_refresh as refresh -from loopx.control_plane.work_items.next_action_writeback_io import ( - NextActionWritebackRejected, next_action_writeback_context, -) +from loopx.control_plane.work_items.recommendation_source_io import RecommendationWritebackRejected from loopx.status import collect_status from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.todos.next_action_runtime import settle_completed_todo_next_action from tests.control_plane import test_todo_projection_concurrency as projection_fixtures canonical_projection = projection_fixtures.canonical_projection @@ -51,142 +50,147 @@ def write(registry, runtime, **kwargs): registry_path=registry, runtime_root_override=str(runtime), goal_id="next-action-goal", project=None, state_file=None, classification="state_refreshed", recommended_action=None, agent_id="agent-a", next_action="Evaluate the new artifact; preserve the incumbent.", - agent_vision_packet=VISION, dry_run=False, sync_global=False, + dry_run=False, sync_global=False, ) options.update(kwargs) return refresh.refresh_state_run(**options) -def test_sole_peer_write_keeps_attribution_scope_and_task_owners(tmp_path): - registry, state, runtime, _ = fixture(tmp_path) - payload = write(registry, runtime) - assert payload["progress_scope"] == "agent_lane" - assert payload["agent_id"] == "agent-a" - assert payload["vision_checkpoint"]["satisfied"] is True - assert payload["active_state_next_action_update"]["agent_id"] == "agent-a" - assert "Evaluate the new artifact; preserve the incumbent." in state.read_text() - assert state.read_text().split("## Next Action")[0] == STATE.split("## Next Action")[0] + +def routes(registry, runtime, root): + status = collect_status(registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[root], limit=5, include_task_graph=True) + item = next(item for item in status["attention_queue"]["items"] if item["goal_id"] == "next-action-goal") + return item, {r["agent_id"]: r for r in item.get("agent_next_actions", [])} + + +@pytest.mark.parametrize("agents", [("agent-a",), ("agent-a", "agent-b")]) +def test_personal_step_reuses_resolution_and_preserves_state_and_ownership(tmp_path, agents): + registry, state, runtime, _ = fixture(tmp_path, agents) + before, selected = routes(registry, runtime, tmp_path) + result = write(registry, runtime, next_action_basis=selected["agent-a"]["next_action_basis"]) + receipt = result["recommended_action_resolution"] + assert receipt["recommended_action_source"] == "agent_lane_step" + assert receipt["todo_id"] == "todo_parser" + assert result["progress_scope"] == "agent_lane" and result["agent_id"] == "agent-a" + assert "active_state_next_action_update" not in result + assert state.read_text() == STATE run = json.loads((runtime / "goals/next-action-goal/runs/index.jsonl").read_text()) - assert run["progress_scope"] == "agent_lane" - assert run["agent_id"] == "agent-a" - - -@pytest.mark.parametrize("agents,scope,basis,code", [ - ([], None, None, "next_action_shared_scope_required"), - (["agent-b"], None, None, None), - (["agent-a", "offline-peer"], None, None, "next_action_shared_scope_required"), - (["agent-a", "agent-b"], "goal", None, "next_action_basis_required"), - (["agent-a"], None, "sha256:" + "0" * 64, "next_action_basis_conflict"), -]) -def test_rejections_do_not_write_state_or_append_runs(tmp_path, agents, scope, basis, code): + assert run["recommended_action_resolution"] == receipt + _, after = routes(registry, runtime, tmp_path) + assert after["agent-a"]["text"] == selected["agent-a"]["text"] + assert after["agent-a"]["next_step"] == result["recommended_action"] + assert after["agent-a"]["next_action_basis"] != selected["agent-a"]["next_action_basis"] + + +def test_multi_agent_steps_are_independent_and_report_scope_does_not_grant_authority(tmp_path): + registry, state, runtime, _ = fixture(tmp_path, ("agent-a", "agent-b")) + _, before = routes(registry, runtime, tmp_path) + write(registry, runtime, next_action_basis=before["agent-a"]["next_action_basis"]) + _, after_a = routes(registry, runtime, tmp_path) + assert after_a["agent-b"]["next_action_basis"] == before["agent-b"]["next_action_basis"] + write(registry, runtime, agent_id="agent-b", next_action="Evaluate the incumbent.", + progress_scope="goal", next_action_basis=before["agent-b"]["next_action_basis"]) + _, after_b = routes(registry, runtime, tmp_path) + assert after_b["agent-a"]["next_step"] == after_a["agent-a"]["next_step"] + assert after_b["agent-b"]["next_step"] == "Evaluate the incumbent." + assert state.read_text() == STATE + + +@pytest.mark.parametrize("agents,actor", [([], "agent-a"), (["agent-b"], "agent-a"), (["agent-a"], None)]) +def test_unknown_or_unattributed_steps_do_not_append(tmp_path, agents, actor): registry, state, runtime, _ = fixture(tmp_path, agents) - with pytest.raises(ValueError) as caught: - write(registry, runtime, progress_scope=scope, next_action_basis=basis) - if code: - assert caught.value.code == code + with pytest.raises(ValueError): + write(registry, runtime, agent_id=actor) assert state.read_text() == STATE assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() -def test_shared_write_uses_status_basis_and_preserves_both_routes(tmp_path): - registry, state, runtime, _ = fixture(tmp_path, ("agent-a", "agent-b")) - status = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) - item = next(item for item in status["attention_queue"]["items"] if item["goal_id"] == "next-action-goal") - assert {r["agent_id"] for r in item["agent_next_actions"]} == {"agent-a", "agent-b"} - basis = item["next_action_basis"] - result = write(registry, runtime, progress_scope="goal", next_action_basis=basis) - assert result["active_state_next_action_update"]["read_basis"] == basis - assert result["active_state_next_action_update"]["applied_basis"] != basis - after = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) - item_after = next(item for item in after["attention_queue"]["items"] if item["goal_id"] == "next-action-goal") - assert [(r["agent_id"], r["todo_id"]) for r in item_after["agent_next_actions"]] == [("agent-a", "todo_parser"), ("agent-b", "todo_evaluate")] - assert state.read_text().split("## Next Action")[0] == STATE.split("## Next Action")[0] - - -@pytest.mark.parametrize("change_membership", [False, True]) -def test_final_commit_rechecks_state_and_membership(tmp_path, monkeypatch, change_membership): +def test_dry_run_checks_binding_without_writing_or_appending(tmp_path): registry, state, runtime, _ = fixture(tmp_path) - original = refresh.qualify_refresh_replan_writeback - def concurrent_change(**kwargs): - result = original(**kwargs) - if change_membership: - data = json.loads(registry.read_text()) - data["goals"][0]["coordination"]["registered_agents"].append("offline-peer") - registry.write_text(json.dumps(data)) - else: - state.write_text(STATE.replace("Preserve the current shared route.", "A newer session chose this route.")) - return result - monkeypatch.setattr(refresh, "qualify_refresh_replan_writeback", concurrent_change) - with pytest.raises(NextActionWritebackRejected) as caught: - write(registry, runtime) - assert caught.value.code == "next_action_basis_conflict" - assert caught.value.payload["next_action_writeback"]["next_action_entries"] - assert "Evaluate the new artifact; preserve the incumbent." not in state.read_text() + result = write(registry, runtime, dry_run=True) + assert result["recommended_action_resolution"]["todo_id"] == "todo_parser" + assert result["appended"] is False + assert state.read_text() == STATE assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() -def test_same_actor_concurrent_cli_writers_cannot_commit_the_same_old_basis(tmp_path): +def test_explicit_matching_step_keeps_task_binding_and_is_not_replan_evidence(tmp_path): + registry, state, runtime, _ = fixture(tmp_path) + step = "Compare the current artifact." + result = write(registry, runtime, next_action=step, recommended_action=step) + assert result["recommended_action_resolution"]["todo_id"] == "todo_parser" + assert result["vision_checkpoint"]["required"] is False + assert not result.get("autonomous_replan_ack") + with pytest.raises(ValueError, match="must agree"): + write(registry, runtime, next_action=step, recommended_action="A different direction.") + + +def test_same_actor_concurrent_cli_writers_cannot_commit_one_old_basis_twice(tmp_path): registry, state, runtime, goal = fixture(tmp_path) - basis = next_action_writeback_context(goal, STATE, source_registry=registry)["basis"] - vision_path = tmp_path / "vision.json" - vision_path.write_text(json.dumps(VISION)) + _, selected = routes(registry, runtime, tmp_path) + basis = selected["agent-a"]["next_action_basis"] def run(action): command = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry), "--runtime-root", str(runtime), "refresh-state", "--goal-id", goal["id"], - "--agent-id", "agent-a", "--next-action", action, "--next-action-basis", basis, - "--agent-vision-json", str(vision_path), "--no-global-sync"] + "--agent-id", "agent-a", "--next-action", action, "--next-action-basis", basis, "--no-global-sync"] result = subprocess.run(command, text=True, capture_output=True, timeout=30) assert result.stdout, result.stderr return result.returncode, json.loads(result.stdout) with ThreadPoolExecutor(max_workers=2) as workers: - results = list(workers.map(run, ["Inspect new parser evidence.", "Evaluate the new artifact."])) + results = list(workers.map(run, ["Inspect new evidence.", "Evaluate the artifact."])) assert sorted(code for code, _ in results) == [0, 1], results - failure = next(payload for code, payload in results if code) - assert failure["error_code"] == "next_action_basis_conflict" + assert next(payload for code, payload in results if code)["error_code"] == "next_action_basis_conflict" assert len((runtime / "goals/next-action-goal/runs/index.jsonl").read_text().splitlines()) == 1 -def test_basis_covers_untruncated_state_and_legacy_roster_sources(tmp_path): - _, _, _, goal = fixture(tmp_path) - first = next_action_writeback_context(goal, STATE + "\n" + "x" * 500) - second = next_action_writeback_context(goal, STATE + "\n" + "x" * 501) - assert first["basis"] != second["basis"] - goal["spawn_policy"] = {"registered_agents": ["offline-peer"]} - assert next_action_writeback_context(goal, STATE)["registered_agents"] == ["agent-a", "offline-peer"] - - -def test_dry_run_checks_admission_without_writing_or_appending(tmp_path): - registry, state, runtime, goal = fixture(tmp_path) - result = write(registry, runtime, dry_run=True) - assert result["active_state_next_action_update"]["would_update"] is True - assert result["active_state_next_action_update"]["updated"] is False - assert state.read_text() == STATE - assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() - goal["coordination"]["registered_agents"].append("offline-peer") - registry.write_text(json.dumps({"goals": [goal]})) - with pytest.raises(NextActionWritebackRejected): - write(registry, runtime, dry_run=True) - - -def test_single_peer_permission_does_not_bypass_vision_continuity_rules(tmp_path): +@pytest.mark.parametrize("change", ["task", "roster", "intent"]) +def test_final_commit_rechecks_relevant_source_facts(tmp_path, monkeypatch, change): registry, state, runtime, _ = fixture(tmp_path) - with pytest.raises(ValueError, match="in_flight_continuation"): - write(registry, runtime, delivery_boundary="in_flight_continuation", delivery_outcome="outcome_progress") - assert state.read_text() == STATE + original = refresh.qualify_refresh_replan_writeback + def concurrent_change(**kwargs): + result = original(**kwargs) + if change == "roster": + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("offline-peer") + registry.write_text(json.dumps(data)) + elif change == "task": + state.write_text(STATE.replace("Inspect the parser.", "Inspect the changed parser contract.")) + else: + state.write_text("# A changed acceptance basis\n" + STATE) + return result + monkeypatch.setattr(refresh, "qualify_refresh_replan_writeback", concurrent_change) + with pytest.raises(RecommendationWritebackRejected) as caught: + write(registry, runtime) + assert caught.value.code == "next_action_basis_conflict" assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() -@pytest.mark.parametrize("field,value", [ - ("goal_instance_id", "new-instance"), ("status", "paused"), ("state_file", "other-state.md"), -]) -def test_basis_fences_goal_identity_lifecycle_and_route_changes(tmp_path, field, value): - registry, state, runtime, goal = fixture(tmp_path) - basis = next_action_writeback_context(goal, STATE)["basis"] - goal[field] = value - assert next_action_writeback_context(goal, STATE)["basis"] != basis - # A bounded status projection must not introduce a new revision. - enriched = {**goal, "latest_runs": [{"classification": "state_refreshed"}], "quota": {"remaining": 1}} - assert next_action_writeback_context(enriched, STATE)["basis"] == next_action_writeback_context(goal, STATE)["basis"] +def test_step_does_not_destroy_binding_or_block_completion_projection(tmp_path): + registry, state, runtime, _ = fixture(tmp_path, ("agent-a", "agent-b")) + bound = STATE.replace("- Preserve the current shared route.", + "- [P1] Inspect the parser.\n") + state.write_text(bound) + write(registry, runtime) + assert state.read_text() == bound + completed = bound.replace("todo_parser status=open", "todo_parser status=done").splitlines() + assert settle_completed_todo_next_action(completed, completed_todo_id="todo_parser") + assert "todo_id=todo_evaluate" in "\n".join(completed) + state.write_text("\n".join(completed)) + _, after = routes(registry, runtime, tmp_path) + assert "agent-a" not in after + assert "next_step" not in after["agent-b"] + + +def test_task_change_invalidates_step_and_no_eligible_task_cannot_accept_one(tmp_path): + registry, state, runtime, _ = fixture(tmp_path) + write(registry, runtime) + state.write_text(STATE.replace("Inspect the parser.", "Inspect another parser interface.")) + _, changed = routes(registry, runtime, tmp_path) + assert "next_step" not in changed["agent-a"] + state.write_text(STATE.replace("todo_parser status=open", "todo_parser status=blocked")) + with pytest.raises(RecommendationWritebackRejected, match="No selected eligible"): + write(registry, runtime) def test_missing_goal_fails_at_the_next_action_boundary(tmp_path): @@ -194,7 +198,6 @@ def test_missing_goal_fails_at_the_next_action_boundary(tmp_path): registry.write_text(json.dumps({"goals": []})) with pytest.raises(ValueError, match="requires a registry Goal"): write(registry, runtime, project=tmp_path, state_file=state) - assert state.read_text() == STATE def shared_fixture(tmp_path): @@ -207,21 +210,13 @@ def shared_fixture(tmp_path): return registry, mirror, state, runtime, goal -def test_stale_shared_roster_cannot_grant_sole_peer_authority(tmp_path): - registry, mirror, state, runtime, goal = shared_fixture(tmp_path) - with pytest.raises(NextActionWritebackRejected) as caught: - write(mirror, runtime) - assert caught.value.code == "next_action_shared_scope_required" +def test_shared_reads_and_writes_use_source_roster_tasks_and_route(tmp_path): + registry, mirror, state, runtime, _ = shared_fixture(tmp_path) + _, selected = routes(mirror, runtime, tmp_path) + assert set(selected) == {"agent-a", "agent-b"} + result = write(mirror, runtime, next_action_basis=selected["agent-a"]["next_action_basis"]) + assert result["recommended_action_resolution"]["todo_id"] == "todo_parser" assert state.read_text() == STATE - status = collect_status(registry_path=mirror, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) - item = next(item for item in status["attention_queue"]["items"] if item["goal_id"] == goal["id"]) - basis = next_action_writeback_context(goal, STATE, source_registry=registry)["basis"] - assert item["next_action_basis"] == basis - assert {route["agent_id"] for route in item["agent_next_actions"]} == {"agent-a", "agent-b"} - result = write(mirror, runtime, progress_scope="goal", next_action_basis=basis) - assert result["agent_id"] == "agent-a" - assert result["active_state_next_action_update"]["read_basis"] == basis - assert "Evaluate the new artifact" in state.read_text() assert "stale mirror route" in (tmp_path / "stale-state.md").read_text() @@ -230,55 +225,58 @@ def test_missing_shared_source_never_mints_a_basis_or_allows_write(tmp_path): registry.unlink() with pytest.raises(ValueError, match="source_registry is missing"): write(mirror, runtime) - assert state.read_text() == STATE - status = collect_status(registry_path=mirror, runtime_root_override=str(runtime), scan_roots=[tmp_path], limit=5, include_task_graph=True) - assert all("next_action_basis" not in item for item in status["attention_queue"]["items"]) - - -@pytest.mark.parametrize("change_route", [False, True]) -def test_shared_source_is_rechecked_before_commit(tmp_path, monkeypatch, change_route): - registry, mirror, state, runtime, goal = shared_fixture(tmp_path) - basis = next_action_writeback_context(goal, STATE, source_registry=registry)["basis"] - original = refresh.qualify_refresh_replan_writeback - def concurrent_change(**kwargs): - result = original(**kwargs) - if change_route: - successor = tmp_path / "successor-registry.json" - successor.write_text(registry.read_text()) - data = json.loads(mirror.read_text()) - data["goals"][0]["source_registry"] = str(successor) - mirror.write_text(json.dumps(data)) - else: - data = json.loads(registry.read_text()) - data["goals"][0]["coordination"]["registered_agents"].append("offline-peer") - registry.write_text(json.dumps(data)) - return result - monkeypatch.setattr(refresh, "qualify_refresh_replan_writeback", concurrent_change) - with pytest.raises(ValueError, match="source registry changed|read basis changed"): - write(mirror, runtime, progress_scope="goal", next_action_basis=basis) - assert state.read_text() == STATE - assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() + item, _ = routes(mirror, runtime, tmp_path) + assert "recommendation_context" not in item -def test_sole_peer_prose_write_preserves_real_canonical_authority(canonical_projection): +def test_step_preserves_real_canonical_authority(canonical_projection): args, state, _, _ = canonical_projection registry = args["registry_path"] data = json.loads(registry.read_text()) data["goals"][0]["coordination"] = {"registered_agents": ["agent-a"]} registry.write_text(json.dumps(data)) before = read_canonical_todos_if_promoted(runtime_root=args["runtime_root"], goal_id=args["goal_id"]) + state_before = state.read_text() result = refresh.refresh_state_run( registry_path=registry, runtime_root_override=str(args["runtime_root"]), goal_id=args["goal_id"], project=None, state_file=None, classification="state_refreshed", recommended_action=None, - agent_id="agent-a", agent_vision_packet=VISION, next_action="Validate the canonical work.", - dry_run=False, sync_global=False, - ) - assert result["progress_scope"] == "agent_lane" - assert result["projection_delivery"] == "delivered" - assert "Validate the canonical work." in state.read_text() - assert "Canonical work" in state.read_text() - assert "Human narrative." in state.read_text() + agent_id="agent-a", next_action="Validate the canonical work.", dry_run=False, sync_global=False) + assert result["recommended_action_resolution"]["recommended_action_source"] == "agent_lane_step" after = read_canonical_todos_if_promoted(runtime_root=args["runtime_root"], goal_id=args["goal_id"]) assert (after["provider_revision"], after["cursor"], after["todos"]) == ( - before["provider_revision"], before["cursor"], before["todos"], - ) + before["provider_revision"], before["cursor"], before["todos"]) + from loopx.control_plane.todos.projection_document import TodoProjectionDocument + assert TodoProjectionDocument.parse(state.read_text()).narrative.strip() == TodoProjectionDocument.parse(state_before).narrative.strip() + from loopx.status import active_state_todo_fields as read_fields + fields = read_fields(data["goals"][0], runtime_root=args["runtime_root"], registry_path=registry, include_agent_next_actions=True) + assert fields["agent_next_actions"][0]["next_step"] == "Validate the canonical work." + + +def test_canonical_step_uses_tasks_without_a_markdown_display(canonical_projection): + args, state, _, _ = canonical_projection + registry = args["registry_path"] + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"] = {"registered_agents": ["agent-a"]} + registry.write_text(json.dumps(data)) + state.unlink() + result = refresh.refresh_state_run( + registry_path=registry, runtime_root_override=str(args["runtime_root"]), goal_id=args["goal_id"], + project=None, state_file=None, classification="state_refreshed", recommended_action=None, + agent_id="agent-a", next_action="Read canonical work.", dry_run=False, sync_global=False) + assert result["recommended_action_resolution"]["todo_id"] == "todo_work" + from loopx.status import active_state_todo_fields as read_fields + fields = read_fields(data["goals"][0], runtime_root=args["runtime_root"], + registry_path=registry, include_agent_next_actions=True) + assert fields["agent_next_actions"][0]["next_step"] == "Read canonical work." + + +def test_unavailable_canonical_reader_never_uses_stale_markdown(tmp_path, monkeypatch): + from loopx.control_plane.work_items import refresh_recommendation + registry, state, runtime, _ = fixture(tmp_path) + def unavailable(**kwargs): + raise RuntimeError("canonical source is unavailable") + monkeypatch.setattr(refresh_recommendation, "read_canonical_todos_if_promoted", unavailable) + with pytest.raises(RuntimeError, match="canonical source is unavailable"): + write(registry, runtime) + assert state.read_text() == STATE + assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() diff --git a/tests/control_plane/test_refresh_checkpoint_isolation.py b/tests/control_plane/test_refresh_checkpoint_isolation.py index c82944278b..02707a9bd0 100644 --- a/tests/control_plane/test_refresh_checkpoint_isolation.py +++ b/tests/control_plane/test_refresh_checkpoint_isolation.py @@ -172,7 +172,8 @@ def send_notification(**kwargs): assert first["vision_checkpoint"]["decision"] == "missing_required" original_record = Path(first["json_path"]).read_bytes() original_state = state_path.read_bytes() - assert b"Verify the scoped delivery evidence." in original_state + assert b"Verify the scoped delivery evidence." not in original_state + assert first["recommended_action_resolution"]["recommended_action_source"] == "agent_lane_step" stdout = first_stdout if hint_source == "first" else run(original, "markdown") if hint_source == "replay": assert "- recovery: `replay`" in stdout diff --git a/tests/control_plane/test_refresh_checkpoint_recovery.py b/tests/control_plane/test_refresh_checkpoint_recovery.py index 24124d03e4..6ea9324edc 100644 --- a/tests/control_plane/test_refresh_checkpoint_recovery.py +++ b/tests/control_plane/test_refresh_checkpoint_recovery.py @@ -215,7 +215,9 @@ def test_same_turn_checkpoint_supplement_with_read_context_is_idempotent(tmp_pat state_path = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" original_state = state_path.read_bytes() if mutation: - assert mutation[1] in original_state.decode("utf-8") + assert mutation[1] not in original_state.decode("utf-8") + assert first["recommended_action_resolution"]["recommended_action_source"] == "agent_lane_step" + assert first["recommended_action_resolution"]["recommended_action"] == mutation[1] supplement = ( ( "--vision-unchanged-reason", diff --git a/tests/control_plane/test_shadow_observable_e2e.py b/tests/control_plane/test_shadow_observable_e2e.py index c4425b2b28..c6541a8378 100644 --- a/tests/control_plane/test_shadow_observable_e2e.py +++ b/tests/control_plane/test_shadow_observable_e2e.py @@ -170,19 +170,20 @@ def test_lease_arguments_cas_transfer_and_replay(caller: Caller) -> None: assert w.call('task-lease', 'inspect', '--todo-id', todo)['ok'] is True -def test_refresh_and_reward_owned_prose(caller: Caller) -> None: +def test_refresh_bound_step_and_reward_owned_prose(caller: Caller) -> None: w = caller todo = w.add('Canonical record must survive prose') record = w.read(todo) args = ('refresh-state', '--agent-id', 'agent-a', '--progress-scope', 'goal', '--classification', 'continue', - '--recommended-action', 'Inspect persisted arguments.', '--vision-unchanged-reason', 'Same bounded validation.', + '--recommended-action', 'Read the independent lease snapshot.', '--vision-unchanged-reason', 'Same bounded validation.', '--next-action', 'Read the independent lease snapshot.', '--no-global-sync') before = w.primary() assert w.call(*args, '--dry-run')['ok'] is True assert w.primary() == before refreshed = w.call(*args) assert refreshed['ok'] is True, refreshed - assert 'Read the independent lease snapshot.' in w.state.read_text() + assert 'Read the independent lease snapshot.' not in w.state.read_text() + assert refreshed['recommended_action_resolution']['recommended_action_source'] == 'agent_lane_step' assert w.read(todo) == record args = ('reward', '--actor-kind', 'owner', '--recorded-at', '2026-09-01T12:00:00+00:00', '--decision', 'continue', '--reward', 'positive', '--reason-summary', 'Retained argument evidence.', '--write-active-state-summary') diff --git a/tests/control_plane/test_shadow_writer_boundaries.py b/tests/control_plane/test_shadow_writer_boundaries.py index 908eca869c..95e3a59528 100644 --- a/tests/control_plane/test_shadow_writer_boundaries.py +++ b/tests/control_plane/test_shadow_writer_boundaries.py @@ -432,7 +432,6 @@ def test_prose_guard_ignores_resume_evaluation_clock( from loopx.control_plane.coordination.runtime_shadow_writer_adapter import ( require_prose_state_write_allowed, ) - from loopx.state_refresh import replace_next_action_section registry, state, root = fixture(tmp_path) add_goal_todo( @@ -444,12 +443,7 @@ def test_prose_guard_ignores_resume_evaluation_clock( resume_when="resume_at:2099-01-01T00:00:00Z", ) original = state.read_text(encoding="utf-8") - planned, changed = replace_next_action_section( - original, - next_action="Record the bounded inspection result.", - updated_at="2026-09-21T00:00:00Z", - ) - assert changed is True + planned = original + "\nA bounded narrative observation.\n" from loopx.control_plane.coordination.local_authority_shadow_adapter import ( todo_partition_projector, @@ -537,9 +531,13 @@ def test_override_root_is_the_only_maintenance_authority(tmp_path: Path) -> None @pytest.mark.parametrize("writer", ["todo", "prose"]) def test_override_root_cannot_bypass_registry_source_maintenance(tmp_path: Path, writer: str) -> None: from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path - from loopx.state_refresh import refresh_state_run - registry, state, root = fixture(tmp_path) + from loopx.feedback import append_human_reward + registry, state, index, reward = reward_fixture(tmp_path) + root = tmp_path / "runtime" override = tmp_path / "override" + override_index = override / "goals" / GOAL / "runs" / "index.jsonl" + override_index.parent.mkdir(parents=True) + override_index.write_bytes(index.read_bytes()) management = shadow_management_state_path(root, GOAL) management.parent.mkdir(parents=True) management.write_text("{}") @@ -551,26 +549,29 @@ def test_override_root_cannot_bypass_registry_source_maintenance(tmp_path: Path, role="agent", text="Cannot bypass source maintenance.", ) else: - refresh_state_run(registry_path=registry, runtime_root_override=str(override), goal_id=GOAL, - project=None, state_file=None, classification="continue", recommended_action="Continue inspection.", - next_action="Cannot bypass source maintenance.", dry_run=False, sync_global=False) + append_human_reward(registry_path=registry, runtime_root_override=str(override), + goal_id=GOAL, run_generated_at=None, reward=reward, actor_kind="owner", + write_active_state_summary=True) assert state.read_bytes() == before def test_override_root_keeps_prose_writable_with_an_active_source_binding(tmp_path: Path) -> None: - from loopx.state_refresh import refresh_state_run - registry, state, _root = fixture(tmp_path) + from loopx.feedback import append_human_reward + registry, state, index, reward = reward_fixture(tmp_path) value = json.loads(registry.read_text()) value["goals"][0]["coordination"]["runtime_shadow"] = { "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0"} registry.write_text(json.dumps(value)) cli(registry, "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute") override = tmp_path / "override" - refreshed = refresh_state_run(registry_path=registry, runtime_root_override=str(override), goal_id=GOAL, - project=None, state_file=None, classification="continue", recommended_action="Continue inspection.", - next_action="Only this owned prose changes.", dry_run=False, sync_global=False) - assert refreshed["ok"] is True - assert "Only this owned prose changes." in state.read_text() + override_index = override / "goals" / GOAL / "runs" / "index.jsonl" + override_index.parent.mkdir(parents=True) + override_index.write_bytes(index.read_bytes()) + refreshed = append_human_reward(registry_path=registry, runtime_root_override=str(override), + goal_id=GOAL, run_generated_at=None, reward=reward, actor_kind="owner", + write_active_state_summary=True) + assert refreshed["appended"] is True + assert "Review accepted." in state.read_text() assert not (override / "authority-shadow" / "outbox" / GOAL).exists() @@ -670,21 +671,6 @@ def test_registry_missing_state_reconstruction_respects_maintenance(tmp_path: Pa assert registry.read_bytes() == before -def test_refresh_owned_next_action_holds_before_state_change(tmp_path: Path) -> None: - from loopx.state_refresh import refresh_state_run - from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path - registry, state, root = fixture(tmp_path) - management = shadow_management_state_path(root, GOAL) - management.parent.mkdir(parents=True) - management.write_text("{}") - before = state.read_bytes() - with pytest.raises(ShadowManagementError): - refresh_state_run(registry_path=registry, runtime_root_override=None, goal_id=GOAL, - project=None, state_file=None, classification="continue", recommended_action="Continue inspection.", - next_action="Read the next source.", dry_run=False, sync_global=False) - assert state.read_bytes() == before - assert not (root / "goals" / GOAL / "runs" / "index.jsonl").exists() - def test_active_capture_prepare_failure_holds_primary_before_any_transition(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox @@ -757,49 +743,15 @@ def fail_prepare(path: Path, record: object) -> None: assert state.read_bytes() == before -def test_concurrent_public_refresh_preserves_the_newer_owned_paragraph(tmp_path: Path) -> None: - registry, state, _root = fixture(tmp_path) - code = """ -import sys -from contextlib import contextmanager -from pathlib import Path -from loopx import state_refresh -original = state_refresh.exclusive_cross_runtime_file_lock -observed = 0 -@contextmanager -def paused(path, *args, **kwargs): - global observed - with original(path, *args, **kwargs) as held: - yield held - if Path(path).name == 'ACTIVE_GOAL_STATE.md': - observed += 1 - if observed == 1: - print('refresh-plan-ready', flush=True) - sys.stdin.readline() -state_refresh.exclusive_cross_runtime_file_lock = paused -from loopx.entrypoint import main -raise SystemExit(main(sys.argv[1:])) -""" - command = ["--registry", str(registry), "--format", "json", "refresh-state", "--goal-id", GOAL, - "--classification", "continue", "--recommended-action", "Retain the selected next action.", - "--next-action", "Stale planned paragraph.", "--no-global-sync"] - child = subprocess.Popen([sys.executable, "-c", code, *command], cwd=REPO, - text=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - try: - assert child.stdout is not None - assert child.stdout.readline().strip() == "refresh-plan-ready" - second = cli(registry, "--runtime-root", str(tmp_path / "parallel-runtime"), - "refresh-state", "--goal-id", GOAL, "--classification", "continue", - "--recommended-action", "Retain the selected next action.", "--next-action", - "Newer committed paragraph.", "--no-global-sync") - assert second["ok"] is True - before = state.read_bytes() - output, error = child.communicate("continue\n", timeout=30) - assert child.returncode == 1, output + error - assert "changed while refresh-state was qualifying" in json.loads(output)["error"] - assert state.read_bytes() == before - assert "Newer committed paragraph." in state.read_text() - finally: - if child.poll() is None: - child.kill() - child.communicate(timeout=10) +def test_public_refresh_step_does_not_write_owned_paragraph(tmp_path: Path) -> None: + registry, state, root = fixture(tmp_path) + add_goal_todo(registry_path=registry, goal_id=GOAL, role="agent", + text="Inspect the bounded result.", task_class="advancement_task", agent_id="agent-a") + before = state.read_bytes() + result = cli(registry, "refresh-state", "--goal-id", GOAL, "--agent-id", "agent-a", + "--next-action", "Compare the current evidence.", "--no-global-sync") + assert result["ok"] is True + assert result["recommended_action_resolution"]["recommended_action_source"] == "agent_lane_step" + assert state.read_bytes() == before + assert "Compare the current evidence." not in state.read_text() + assert not (root / "authority-shadow").exists() diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 1f07ea8ea7..1992d56fe8 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -132,6 +132,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/work_items/operation_agent_handoff.ts", "control_plane/work_items/pending_capability_intent.ts", "control_plane/work_items/replan_history_snapshot.ts", + "control_plane/work_items/refresh_recommendation.ts", "control_plane/work_items/task_lease_acquire.ts", "control_plane/work_items/task_lease_lifecycle.ts", "control_plane/work_items/task_lease_lifecycle_request.ts", diff --git a/tests/control_plane_ts/next_action_writeback.test.ts b/tests/control_plane_ts/next_action_writeback.test.ts deleted file mode 100644 index 9d8391419b..0000000000 --- a/tests/control_plane_ts/next_action_writeback.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import {resolveNextActionWriteback as resolve} from "../../loopx/control_plane/work_items/next_action_writeback.ts"; - -const source = { - goal_id: "goal-next-action", goal_revision: "sha256:" + "c".repeat(64), - state_revision: "sha256:" + "a".repeat(64), registered_agents: ["agent-a"], - next_action_entries: ["Keep the current route"], -}; -const lane = {agent_id: "agent-a", progress_scope: "agent_lane"}; -test("sole registered actor may update prose without changing report scope", () => { - assert.equal(resolve({...source, write: lane}).admitted, true); -}); -test("empty roster, unknown actor, and offline peers never qualify as a sole actor", () => { - assert.equal(resolve({...source, registered_agents: [], write: lane}).admitted, false); - assert.equal(resolve({...source, write: {...lane, agent_id: "agent-b"}}).admitted, false); - assert.equal(resolve({...source, registered_agents: ["agent-a", "offline-peer"], write: lane}).admitted, false); -}); -test("multi-peer shared writes require actor, goal scope, and exact basis", () => { - const shared = {...source, registered_agents: ["agent-a", "agent-b"]}; - const basis = resolve(shared).basis; - const write = {agent_id: "agent-b", progress_scope: "goal", expected_basis: basis}; - assert.equal(resolve({...shared, write}).admitted, true); - assert.equal(resolve({...shared, write: {...write, expected_basis: null}}).error_code, "next_action_basis_required"); - assert.equal(resolve({...shared, write: {...write, agent_id: null}}).admitted, false); - assert.equal(resolve({...shared, write: {...write, progress_scope: "agent_lane"}}).admitted, false); - assert.equal(resolve({...shared, write: {...write, source_basis: basis, expected_basis: null}}).error_code, "next_action_basis_required"); -}); -test("invocation source basis also fences admission, without becoming caller authority", () => { - assert.equal(resolve({...source, write: {...lane, source_basis: "sha256:" + "0".repeat(64)}}).error_code, "next_action_basis_conflict"); -}); -test("old prose, membership, lifecycle and instance bases conflict, including same-actor writes", () => { - const write = {...lane, expected_basis: resolve(source).basis}; - for (const change of [ - {state_revision: "sha256:" + "b".repeat(64)}, {registered_agents: ["agent-a", "agent-b"]}, - {goal_revision: "sha256:" + "d".repeat(64)}, {goal_id: "other-goal"}, - ]) { - assert.equal(resolve({...source, ...change, write}).error_code, "next_action_basis_conflict"); - } -}); -test("roster ordering and duplicate identities do not create conflicts", () => { - const shared = {...source, registered_agents: ["agent-b", "agent-a"]}; - assert.equal(resolve({...shared, registered_agents: ["agent-a", "agent-b", "agent-a"]}).basis, resolve(shared).basis); -}); -test("unscoped legacy goal write remains admitted; malformed basis fails fast", () => { - assert.equal(resolve({...source, registered_agents: [], write: {progress_scope: "goal"}}).admitted, true); - assert.throws(() => resolve({...source, write: {...lane, expected_basis: "old"}}), /SHA-256 basis/); -}); diff --git a/tests/control_plane_ts/refresh_recommendation.test.ts b/tests/control_plane_ts/refresh_recommendation.test.ts index bd5185c26d..eb1639be73 100644 --- a/tests/control_plane_ts/refresh_recommendation.test.ts +++ b/tests/control_plane_ts/refresh_recommendation.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { resolveRefreshRecommendation, + resolveLaneRecommendation, } from "../../loopx/control_plane/work_items/refresh_recommendation.ts"; const baseRequest = { @@ -148,3 +149,57 @@ test("explicit recommendation remains authoritative", () => { assert.equal(result.authority, "explicit"); assert.equal(result.settlement_alignment, "not_applicable"); }); + +const laneContext = { + goal_id: "goal-shared", source_revision: "sha256:" + "a".repeat(64), + registered_agents: ["agent-a", "agent-b"], agent_id: "agent-a", + selected_todo: baseRequest.agent_lane_candidate, + task_facts: baseRequest.agent_lane_candidate, prior_resolution: null, +}; + +test("a lane step refines the selected task without rewriting its identity or text", () => { + const read = resolveLaneRecommendation(laneContext); + const result = resolveLaneRecommendation({...laneContext, write: { + text: "Try a smaller experiment.", expected_basis: read.basis, + }}); + assert.equal(result.admitted, true); + const resolution = result.resolution as Record; + assert.equal(resolution.todo_id, "todo_higher_priority"); + assert.equal(resolution.recommended_action_source, "agent_lane_step"); + const projected = resolveLaneRecommendation({...laneContext, prior_resolution: resolution}); + const selected = projected.selected_todo as Record; + assert.equal(selected.text, baseRequest.agent_lane_candidate.text); + assert.equal(selected.next_step, "Try a smaller experiment."); + assert.notEqual(projected.basis, read.basis); +}); + +test("a peer's step is not adopted; a stale task or source cannot resurrect a step", () => { + const first = resolveLaneRecommendation({...laneContext, write: {text: "Try an experiment."}}); + for (const change of [ + {agent_id: "agent-b"}, + {task_facts: {...laneContext.task_facts, updated_at: "later"}}, + {source_revision: "sha256:" + "b".repeat(64)}, + {selected_todo: {...laneContext.selected_todo, status: "done"}}, + ]) { + const result = resolveLaneRecommendation({...laneContext, ...change, prior_resolution: first.resolution}); + assert.equal((result.selected_todo as Record).next_step, undefined); + } +}); + +test("ordinary personal writeback needs no goal report scope; the same actor's old basis conflicts", () => { + const read = resolveLaneRecommendation(laneContext); + const first = resolveLaneRecommendation({...laneContext, write: {text: "Inspect evidence.", expected_basis: read.basis}}); + const conflict = resolveLaneRecommendation({...laneContext, prior_resolution: first.resolution, + write: {text: "Replace the old step.", expected_basis: read.basis}}); + assert.equal(conflict.admitted, false); + assert.equal(conflict.error_code, "next_action_basis_conflict"); +}); + +test("unknown actors, absent tasks and peer-owned tasks fail closed", () => { + for (const change of [ + {registered_agents: []}, {agent_id: "unknown"}, {selected_todo: null}, + {selected_todo: {...laneContext.selected_todo, claimed_by: "agent-b"}}, + ]) { + assert.equal(resolveLaneRecommendation({...laneContext, ...change, write: {text: "Try."}}).admitted, false); + } +}); diff --git a/tests/control_plane_ts/todo_next_action.test.ts b/tests/control_plane_ts/todo_next_action.test.ts index 90b42be52f..e4a4cfabf4 100644 --- a/tests/control_plane_ts/todo_next_action.test.ts +++ b/tests/control_plane_ts/todo_next_action.test.ts @@ -5,9 +5,21 @@ import { NEXT_ACTION_BINDING_SCHEMA, TODO_NEXT_ACTION_REQUEST_SCHEMA, transitionTodoNextAction, + projectNextActionBinding, type TodoNextActionSnapshot, } from "../../loopx/control_plane/todos/next_action.ts"; +test("read projection trusts one typed binding, never IDs mentioned in prose", () => { + const marker = ``; + assert.deepEqual(projectNextActionBinding({lines: ["## Next Action", "- Inspect the task.", marker]}), {todo_id: "todo_task"}); + for (const lines of [ + ["## Next Action", "- Inspect todo_task."], + ["## Next Action", "- Inspect the task.", marker.replace(NEXT_ACTION_BINDING_SCHEMA, "unknown_v9")], + ["## Next Action", "- Inspect the task.", marker, marker], + ["## Next Action", "- Inspect the task.", "- Inspect another task.", marker], + ]) assert.deepEqual(projectNextActionBinding({lines}), {todo_id: null}); +}); + function todo( todoId: string, overrides: Partial = {}, From e7502c0f64062e67a01f3b6239ef1ec486b1f78b Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 23:26:02 +0800 Subject: [PATCH 6/8] docs(state): align task steps with canonical authority and Vision boundaries Signed-off-by: huangruiteng --- ...oal-alignment-and-governed-amendment-v0.md | 21 ++-- ...ignment-and-governed-amendment-v0.zh-CN.md | 16 ++- docs/project-agent-todo-contract.md | 17 ++- docs/quota-allocation.md | 112 ++++++++++-------- .../goal-vision-replan-contract-v0.md | 13 +- 5 files changed, 110 insertions(+), 69 deletions(-) diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md index 4396f5143e..53258d0c06 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md @@ -567,13 +567,20 @@ provider-neutral aggregate requires a separate reviewed transaction boundary. `Next Action` remains compatibility prose and a read projection. It is never a claim, lease, Goal amendment, replan settlement, or authority decision. -Compatibility-prose checkpoint: `refresh-state` admits a confirmed sole -registered peer's Next Action write independently of its personal report scope. -Multi-peer reads compose the existing Todo routes; explicit shared-prose writes -require Goal report scope and an exact read basis, rechecked with membership at -commit. See the [writeback contract](../../quota-allocation.md). This does not -advance Stage 3 amendment commit: canonical task mutations, lease/claim checks, -protected intent and acceptance remain with their existing owners. +Integrated recommendation checkpoint: `refresh-state --next-action` evolves +the existing recommendation receipt into an actor-bound, within-Todo step; +it no longer overwrites the compatibility prose. Single and multiple peers +use the same rule. The existing lane selector chooses work, then the TS +recommendation owner decorates it with a still-valid step. Task text and +claim/lease prerequisites remain intact. Peer summaries use those same derived +routes, with no independent plan store. Source/task/actor-step read fences reject +stale writes and discard stale read decorations. See the +[writeback contract](../../quota-allocation.md). + +This advances advisory continuity, not Stage 3 amendment commit. The source-facts +digest is not a canonical intent revision; run history is not the shared +Todo/claim/lease transaction store. Ordinary task changes, protected intent, +acceptance, Vision and replan settlement retain their existing owners. ### 9.1 Semantic handoff and execution-route integration diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md index 8c28c176c7..e1a7b3bac4 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md @@ -489,11 +489,17 @@ projection 与 proposal contract 交付;把 commit 映射进 provider-neutral `Next Action` 继续是 compatibility prose 与 read projection。它永远不是 claim、 lease、Goal amendment、replan settlement 或 authority decision。 -兼容文本写回 checkpoint:`refresh-state` 确认完整注册名单只有当前 peer 时, -允许其在个人进展 scope 中更新 Next Action,保留 agent 归因。多 peer 路线由现有 -Todo selector 汇总;显式共享文本更新仍需 Goal scope 和当前读取依据,并在提交时 -重新检查成员与版本。详见[写回合同](../../quota-allocation.md)。这不推进 Stage 3 -amendment commit;任务修改、claim/lease、受保护意图和验收仍由原 owner 负责。 +融合 recommendation checkpoint:`refresh-state --next-action` 演进现有 +recommendation receipt,记录绑定当前 agent/Todo 的任务内步骤,不再覆盖兼容文本。 +单 peer 和多 peer 使用同一规则:先由现有 lane selector 选任务,再由 TS +recommendation owner 附加仍有效的步骤,保留任务原文及 claim/lease 前置条件。 +各 peer 的汇总复用这些派生路线,不增加独立计划库。来源、任务和当前 agent 步骤 +的读取依据检查拒绝过期写入,并在读时丢弃过期步骤。详见 +[写回合同](../../quota-allocation.md)。 + +这推进 advisory continuity,不代表 Stage 3 amendment commit 已交付。来源事实 +摘要不是 canonical intent revision;run history 不是共享 Todo/claim/lease 事务库。 +普通任务修改、受保护意图、验收、Vision 和 replan settlement 继续由原 owner 负责。 ### 9.1 语义交接与执行路线衔接 diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 45bdb7c0df..9520c530c8 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -411,9 +411,20 @@ collisions without writing broad prompt scope into todo metadata or pretending that a soft claim is already a hard lease. When a runnable current-agent or unclaimed advancement todo exists, quota may also expose `agent_lane_next_action.schema_version=agent_lane_next_action_v0`. That field is -the peer's current slice for this turn; it does not overwrite the durable -goal-level `Next Action`. `loopx status --agent-id ` may attach the same derived field to matching status -queue items for observation, while leaving the project-level route unchanged. +the peer's selected task for this turn. Its `text`, identity, claim/lease and +capability facts remain task facts. Optional `next_step` and `next_action_basis` +come from the existing recommendation receipt, validated against the current +selected Todo before display; they do not select a task or change authority. +`refresh-state --agent-id PEER --next-action TEXT [--next-action-basis BASIS]` +records this within-task step in run history without overwriting the compatibility +`## Next Action` section. Single- and multi-peer Goals share this rule. +`status --agent-id PEER` exposes the same derived lane and basis; +`status --include-task-graph` summarizes all peers' routes. Source and stale-read +boundaries are defined in the [quota contract](quota-allocation.md). +Canonical Todos remain the task source even if their Markdown display is missing +or stale. Promoted provider failure is an error, never permission to select a +legacy Markdown task. Task edits, dependencies and ownership changes continue +through their existing Todo writers; stage direction changes use Vision/replan. When a candidate has `target_capabilities` and missing target bridge capabilities, quota may mark it `capability_repair_mode=true`; scoped next-action selection should prefer that repair-mode candidate over ordinary diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 0a59a17861..a85682090e 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -1105,55 +1105,69 @@ so agent executors know to write newly discovered user/owner work with hiding it in `Next Action`, review docs, or chat. Multi-agent writes must also name `--bound-agent ` or `--goal-bound`; agent-scoped `user_gate` writes bind to the same agent named by `--blocks-agent`. -When available, `quota should-run` also keeps next-action signals separate: -`active_state_next_action` is the durable `## Next Action`, -`latest_run_recommended_action` is the latest non-agent-lane run's -recommendation, and `agent_lane_next_action` is the current `--agent-id` -slice. Agent-scoped payloads may also include -`goal_route_hint.schema_version=goal_route_hint_v0`, a compact read-only -synthesis that says whether the current lane should run, claim, wait, or -reassign while preserving `## Next Action` as durable goal-level guidance. It -is an advisory routing hint, not a writeback instruction and not a replacement -for `agent_todo_summary`. -When projected, `goal_frontier_projection.schema_version = -goal_frontier_projection_v0` is the per-goal progress/frontier view used before -lane-local quiet or wait decisions. Its `autonomous_replan_decision` says that a -required replan must be selected independently of `monitor_quiet_skip` or -`agent_scope_wait`; the policy lives in `loopx.control_plane.goals.goal_frontier`, while -quota only wires the selected mode into `interaction_contract`. -If the active-state and latest-run actions differ, `next_action_projection_warning` -keeps the agent's Todo route distinct from shared compatibility prose. A confirmed -sole registered peer can use `refresh-state --agent-id PEER --next-action TEXT` -without promoting its personal report: `progress_scope` remains `agent_lane`. -Confirmation uses the complete registered roster, including offline peers and -legacy roster sources, rather than presence. Shared registry reads resolve to -the project source registry; a stale mirror cannot grant sole-peer authority. -An unavailable source cannot supply a write basis. Attribution, Vision checkpoints, -settlement, workspace and prose-writer checks still apply. - -For multiple peers, `status --include-task-graph` projects `agent_next_actions` from each peer's existing -Todo selector; changing one route does not replace the others or rewrite the -durable prose. Ordinary Todo and Vision edits keep their existing owners. -An explicit shared prose replacement requires a registered actor, -`--progress-scope goal` and `--next-action-basis BASIS`, where `BASIS` is -`attention_queue.items[].next_action_basis` from `loopx --format json status --goal-id GOAL`. -The same flag is available to a sole peer to reject a plan made against an older -read. Without the flag, sole-peer writes capture their basis at invocation time; -they cannot detect an older planning read outside that invocation. - -The basis binds the complete active-state bytes and registry Goal identity, -membership, lifecycle, instance and routing facts. A stale write -returns `next_action_basis_conflict`, the current basis and a bounded Next Action -readback; reread and rejudge rather than repeating task work or quota spend. -The writer rechecks admission under registry/state locks immediately before -the prose write. This is a compatibility-prose fence, not a canonical Todo -transaction or a governed Goal amendment. Unscoped legacy Goal reports and -refreshes without `--next-action` retain their previous behavior. -`refresh-state` records `recommended_action_source` so hosts can tell whether a -run recommendation came from an explicit argument, durable `## Next Action`, an -Agent Todo compatibility fallback, or the generic default. Dispatch still comes -from `agent_lane_next_action` / todo projection, not from shared `## Next Action` -alone. +When available, `quota should-run` keeps next-action signals separate: +`active_state_next_action` is the compatibility `## Next Action` display, +`latest_run_recommended_action` is a historical run recommendation, and +`agent_lane_next_action` is the currently selected Todo for `--agent-id`. +The lane retains the task's `text`, identity and execution prerequisites; an +optional `next_step` refines what to try within that task. Task selection happens +first. Prose mentioning a Todo id cannot select it; the existing typed Next +Action breadcrumb remains a compatibility read input. + +A registered peer can record its own task step without promoting its report: + +```bash +loopx --format json status --goal-id GOAL --agent-id PEER +loopx refresh-state --goal-id GOAL --agent-id PEER \ + --next-action "Evaluate the alternate approach; retain the incumbent." \ + --next-action-basis BASIS +``` + +`BASIS` is the matching queue item's `next_action_basis`, also exposed in quota's +`agent_lane_next_action.next_action_basis`. The existing +`recommended_action_resolution` receipt records `recommended_action_source= +agent_lane_step`, the actor, selected Todo and read basis in run history. +Semantic history retains the latest bound receipt per actor beyond the recent +run window. Reads attach its step only while the actor, selected task and source +facts still match. A changed, completed, reassigned or blocked task cannot +inherit a stale step. Selecting another task uses the normal Todo owner; this +flag neither overwrites Markdown nor creates another task or route store. + +Single- and multi-agent Goals use the same rule. `progress_scope` still controls +report presentation, not task authority. `status --include-task-graph` includes +`agent_next_actions` derived from each registered peer's Todo selector and valid +step receipt; one peer's step does not replace another's. Shared registry reads +resolve to the project source registry. Missing sources cannot mint a write +basis; promoted canonical provider failures do not fall back to Markdown tasks. + +The basis binds the complete registered roster, source Goal identity/lifecycle/ +routing and narrative facts, the selected task's semantic facts, and the actor's +latest bound step. It is a source-facts fence, **not a canonical Goal intent +revision**. Ordinary peer Todo edits and peer step receipts do not change this +actor's basis. Same-actor concurrent writers using one basis cannot both commit; +`next_action_basis_conflict` returns the current basis and selected task for +reread and rejudgment. The writer rechecks the source under existing locks before +appending the receipt. This does not CAS or lock the canonical Todo transaction; +a concurrent provider commit may leave an obsolete historical step, which the +next read discards. Without the optional flag, it captures a fresh invocation +basis; it cannot detect a model's older planning read outside that invocation. + +**Default behavior change:** `refresh-state --next-action` now requires a +registered `--agent-id` and an eligible selected advancement Todo, for both +personal and Goal-scoped reports. Supplying `--recommended-action` alongside it +requires the same text. It no longer performs a shared prose replacement. +Existing Todo, claim/lease, settlement and Vision checks retain their owners; +a within-task step alone is not a durable mainline change, replan ACK or Goal +amendment. Refreshes without `--next-action` keep their existing recommendation +and report behavior. Historical prose-update receipts remain readable. + +Agent-scoped payloads may also include `goal_route_hint_v0`, a read-only +synthesis of run, claim, wait or reassign advice. It is not a writer or a +replacement for `agent_todo_summary`. `goal_frontier_projection_v0` evaluates a +required replan independently of monitor quiet or agent-scope wait; its owner +remains `loopx.control_plane.goals.goal_frontier`. Next-step decoration does not +change eligibility, replan priority or the requirement to claim/lease work. + When `agent_lane_next_action.selected_by=unclaimed_todo`, the payload marks `claim_required_before_work=true`; executors must claim the todo before editing or launching delivery work. diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 5eeb7fca16..f67b75e942 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -287,9 +287,12 @@ memory or owner reminders. ## Vision Checkpoint `refresh-state` always emits a per-agent `vision_checkpoint_v0`, and defaults -to the `semantic_closeout` delivery boundary. A material delivery outcome or a -durable `## Next Action` update at that boundary requires an explicit vision -decision: +to the `semantic_closeout` delivery boundary. A material delivery outcome at +that boundary requires an explicit vision decision. A within-Todo recommendation +step (`refresh-state --next-action`) is not a durable mainline change and does +not, by itself, trigger a Vision checkpoint or settle a replan. Direction changes +still use the existing Vision/`path_delta` owner. Historical durable prose-update +receipts retain their checkpoint semantics: ```json { @@ -322,8 +325,8 @@ loopx refresh-state \ ``` This boundary is valid only for the selected agent-bound or unclaimed open -advancement Todo while it is still in flight. It rejects Todo completion, a -durable Next Action update, autonomous replan writeback, and any outcome other +advancement Todo while it is still in flight. It permits a bound within-Todo +step, but rejects Todo completion, autonomous replan writeback, and any outcome other than `outcome_progress`. Its checkpoint has `decision=not_required`, `required=false`, and a typed `in_flight_continuation` trigger carrying the Todo id. The next quota decision From 472deff8bf5acb196c11afeb7af23a4b0811616e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 4 Oct 2026 15:36:11 +0800 Subject: [PATCH 7/8] fix(turn): retain host guidance without implicit task-step edits Signed-off-by: huangruiteng --- docs/project-agent-todo-contract.md | 14 +++++--- docs/quota-allocation.md | 14 +++++++- .../control_plane/cli-output-probe-runner.py | 1 + loopx/cli_commands/status.py | 6 ++++ loopx/cli_commands/turn_run_once.py | 5 ++- .../testing/cli_output_differential.py | 18 ++++++++++ .../testing/cli_output_semantics.py | 14 ++++++++ .../test_content_digest_single_owner.py | 1 + .../test_cli_output_differential.py | 35 +++++++++++++++++++ tests/test_loopx_turn_driver.py | 22 ++++++++++-- 10 files changed, 120 insertions(+), 10 deletions(-) diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 9520c530c8..feb771b1d2 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -564,11 +564,15 @@ The default scoped refresh is an agent-lane run: it is useful for keeping the same turn's writeback/accounting identity intact, but it does not replace the goal-level status route. -A confirmed sole registered peer may still update durable compatibility prose -with `--next-action` in that personal report. Multi-peer shared-prose updates -require Goal scope and a current `--next-action-basis`; the read projection -preserves each peer's Todo route. See [Next Action writeback](quota-allocation.md) -for attribution, snapshot conflicts and the unchanged authority boundaries. +`--next-action` records a step bound to the registered Agent's selected eligible +Todo, in either personal or Goal report scope; it does not replace shared prose. +`--next-action-basis` optionally rejects an edit planned from an older source +read. If the basis is stale, read the current task/step and retry; if no eligible +task is available, resolve its existing lifecycle or routing boundary first. +Native Turn host `next_action` remains follow-up guidance in its durable result, +not this explicit task-step edit: completion or repair may leave no runnable +current task. See [Next Action writeback](quota-allocation.md) for attribution, +snapshot conflicts and the unchanged claim, lease and intent boundaries. ## Lifecycle Contract diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index a85682090e..30d8eeeea9 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -1158,9 +1158,21 @@ personal and Goal-scoped reports. Supplying `--recommended-action` alongside it requires the same text. It no longer performs a shared prose replacement. Existing Todo, claim/lease, settlement and Vision checks retain their owners; a within-task step alone is not a durable mainline change, replan ACK or Goal -amendment. Refreshes without `--next-action` keep their existing recommendation +amendment. Native Turn host results keep `recommended_action` and `next_action` +as distinct durable follow-up guidance; they do not implicitly invoke this +task-step editor or replace shared prose, including after completion or repair. +Refreshes without `--next-action` keep their existing recommendation and report behavior. Historical prose-update receipts remain readable. +CLI status omits the reducer's internal source context and duplicate basis; +the selected route and explicit peer detail retain the editable read fence. +The unchanged base/head fixture adds 100 pretty / 94 compact JSON characters +to ordinary quota for that fence. Explicit task-graph detail adds 457 pretty / +346 compact characters for two peer routes after compaction. The differential +guard recognizes this bounded first 0-to-N fence transition only on the affected +JSON views; absolute ceilings and later N-to-N growth budgets remain unchanged. +This is a measured readback cost, not a claim of token or end-to-end time savings. + Agent-scoped payloads may also include `goal_route_hint_v0`, a read-only synthesis of run, claim, wait or reassign advice. It is not a writer or a replacement for `agent_todo_summary`. `goal_frontier_projection_v0` evaluates a diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index 6e9efa4913..6884d081a3 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -126,6 +126,7 @@ def _receipt_row( if isinstance(payload, dict) else [] ), + "next_action_basis_count": semantics.next_action_basis_count(payload), } diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index 27089beda5..55c43c1158 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -259,6 +259,12 @@ def handle_status_command( ) if pending_composition_retries is not None: payload["pending_composition_retry_receipts"] = pending_composition_retries + # Source facts feed the typed recommendation reducer before display. + # They are not another operator-facing plan or a second copy of its basis. + for item in payload.get("attention_queue", {}).get("items", []): + item.pop("recommendation_context", None) + # The selected route (or explicit peer detail) already carries it. + item.pop("next_action_basis", None) except Exception as exc: payload = { "ok": False, diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 653bb708db..631f27b6dc 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -278,7 +278,10 @@ def writeback( state_file=None, classification=str(result["classification"]), recommended_action=str(result["recommended_action"]), - next_action=str(result["next_action"]), + # A host's next_action is follow-up guidance, not refresh-state's + # explicit within-task step edit (which requires a runnable Todo). + # Keep both host texts in the durable host_result, including for + # completion/repair, without decorating a completed or blocked task. delivery_batch_scale=str(result["delivery_batch_scale"]), delivery_outcome=str(result["delivery_outcome"]), delivery_workspace_path=delivery_workspace_path, diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 183a56cd93..db1ba7112f 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -690,6 +690,19 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A ) failures.extend(projection_failures) review_signals.extend(projection_signals) + # A first task-step read fence costs 94 compact chars per occurrence. Peer + # detail also adds its task/actor row. Qualify only the initial 0->N change + # on these JSON views; keep absolute caps and later N->N growth unchanged. + before_basis = base.get("next_action_basis_count", 0) + after_basis = candidate.get("next_action_basis_count", 0) + basis_migration = ( + output_format == "json" + and row_id.startswith(("surface/status/", "surface/quota_should_run/", "variant/status_task_graph_detail/")) + and type(before_basis) is int and before_basis == 0 + and type(after_basis) is int and 0 < after_basis <= 4 + ) + if basis_migration: + review_signals.append("task-bound recommendation read fences added; bounded one-time JSON growth") deltas: dict[str, int | None] = {} allowances: dict[str, int | None] = {} for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): @@ -727,6 +740,11 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A ), _schema_migration_growth_allowance(migration, metric), projection_allowance.get(metric, 0), + after_basis * ( + {"chars": 256, "utf8_bytes": 256, "lines": 8, "compact_payload_chars": 192} + if row_id.startswith("variant/status_task_graph_detail/") else + {"chars": 192, "utf8_bytes": 192, "lines": 6, "compact_payload_chars": 144} + )[metric] if basis_migration else 0, ) # Thin installed prompts contain bilingual lifecycle instructions. A # small character-level clarification can cost three bytes per CJK diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index 0b7abc47fd..7cb1f77e67 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -6,6 +6,8 @@ import shlex from typing import Any +from loopx.control_plane.content_digest import ENVELOPED_SHA256_PATTERN + def heartbeat_user_language_prompt_revision(text: str) -> str | None: """Attribute the one-time user-language prompt transition in CLI probes. @@ -196,6 +198,18 @@ def todo_work_counts_schema_versions(value: Any) -> list[str]: return _schema_versions_for_key(value, "work_counts") +def next_action_basis_count(value: Any) -> int: + """Count rendered read fences for the one-time task-step projection change.""" + if isinstance(value, list): + return sum(next_action_basis_count(child) for child in value) + if not isinstance(value, dict): + return 0 + basis = value.get("next_action_basis") + return int(isinstance(basis, str) and ENVELOPED_SHA256_PATTERN.fullmatch(basis) is not None) + sum( + next_action_basis_count(child) for child in value.values() + ) + + def markdown_headings(text: str) -> list[str]: return [line.strip() for line in text.splitlines() if _MARKDOWN_HEADING.match(line)] diff --git a/tests/architecture/test_content_digest_single_owner.py b/tests/architecture/test_content_digest_single_owner.py index c454d42233..a6852c167c 100644 --- a/tests/architecture/test_content_digest_single_owner.py +++ b/tests/architecture/test_content_digest_single_owner.py @@ -189,6 +189,7 @@ "loopx.control_plane.goals.deletion_service", "loopx.control_plane.goals.goal_amendment_proposal", "loopx.control_plane.projects.registry_codec", + "loopx.control_plane.testing.cli_output_semantics", "loopx.control_plane.testing.release_commit_qualification", "loopx.control_plane.todos.completion_result", "loopx.control_plane.todos.completion_transaction", diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 1174830433..fa5a4edf99 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -1151,3 +1151,38 @@ def test_malformed_command_never_grants_route_growth(command, render) -> None: def test_json_escaped_paths_and_duplicate_arguments_are_counted_once() -> None: command = """loopx --format json --registry '/tmp/a \"quoted\" path' --registry /tmp/final --runtime-root '/tmp/root path' turn plan""" assert command_route_counts(json.dumps({"command": command})) == {"registry": 1, "runtime_root": 1} + + +@pytest.mark.parametrize('row_id', [ + 'surface/status/small/json', 'surface/quota_should_run/small/json', + 'variant/status_task_graph_detail/small/json', +]) +def test_task_step_read_fence_growth_is_bounded_one_time_and_view_scoped(row_id): + from loopx.control_plane.testing.cli_output_differential import _compare_row + + base = _row(row_id=row_id, chars=1000, utf8_bytes=1000, lines=50, + compact_payload_chars=1000, next_action_basis_count=0) + candidate = {**base, 'chars': 1180, 'utf8_bytes': 1180, 'lines': 55, + 'compact_payload_chars': 1130, 'next_action_basis_count': 1} + observed = _compare_row(base, candidate) + assert not observed['failures'] + assert any('read fences' in signal for signal in observed['review_signals']) + limit = 256 if row_id.startswith('variant/') else 192 + assert _compare_row(base, {**candidate, 'chars': 1001 + limit})['failures'] + assert _compare_row({**base, 'next_action_basis_count': 1}, candidate)['failures'] + for invalid in (True, '1', -1, 5): + assert _compare_row(base, {**candidate, 'next_action_basis_count': invalid})['failures'] + for other in ('surface/diagnose/small/json', 'surface/status/small/markdown'): + other_format = 'markdown' if other.endswith('/markdown') else 'json' + assert _compare_row({**base, 'row_id': other, 'format': other_format}, + {**candidate, 'row_id': other, 'format': other_format})['failures'] + + +def test_read_fence_probe_counts_only_canonical_digest_shapes(): + from loopx.control_plane.testing.cli_output_semantics import next_action_basis_count + + valid = 'sha256:' + 'a' * 64 + assert next_action_basis_count({'route': {'next_action_basis': valid}, + 'peers': [{'next_action_basis': valid}]}) == 2 + for invalid in (None, True, 'a' * 64, valid + '\n', 'sha256:' + 'z' * 64): + assert next_action_basis_count({'next_action_basis': invalid}) == 0 diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index 3c135e2d1a..92afb64b26 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -1940,7 +1940,7 @@ def test_turn_cli_requires_complete_resume_identity(tmp_path: Path) -> None: @pytest.mark.parametrize("checkpoint_fault", [None, "writeback", "quota_spend"]) -def test_turn_run_once_cli_commits_validated_result_and_one_quota_slot( +def test_turn_run_once_cli_commits_distinct_host_guidance_without_task_step_edit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, checkpoint_fault: str | None, @@ -2096,13 +2096,23 @@ def interrupt_checkpoint(path, journal): / "loopx-turn-fixture" / "ACTIVE_GOAL_STATE.md" ) - assert "Run the next public fixture check" in state_path.read_text(encoding="utf-8") + assert "Run the next public fixture check" not in state_path.read_text(encoding="utf-8") + journal_path = runtime / "goals" / "loopx-turn-fixture" / "turns" / ( + payload["resume_turn_key"].removeprefix("sha256:") + ".json" + ) + # The host texts have distinct meanings and survive replay without becoming + # an implicit task-step write or replacing shared compatibility prose. + journal = json.loads(journal_path.read_text(encoding="utf-8")) + assert journal["host_result"]["recommended_action"] == "Continue the public fixture" + assert journal["host_result"]["next_action"] == "Run the next public fixture check" index_path = runtime / "goals" / "loopx-turn-fixture" / "runs" / "index.jsonl" rows = [json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines()] assert [row["classification"] for row in rows] == [ "fixture_progress", "quota_slot_spent", ] + assert rows[0]["recommended_action"] == "Continue the public fixture" + assert not rows[0]["recommended_action_resolution"].get("step_revision") resumed_output = io.StringIO() with contextlib.redirect_stdout(resumed_output): @@ -3436,7 +3446,13 @@ def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, / "loopx-turn-fixture" / "ACTIVE_GOAL_STATE.md" ).read_text(encoding="utf-8") - assert "Run one revised public fixture check" in state + journal_path = runtime / "goals" / "loopx-turn-fixture" / "turns" / ( + payload["resume_turn_key"].removeprefix("sha256:") + ".json" + ) + journal = json.loads(journal_path.read_text(encoding="utf-8")) + assert journal["host_result"]["next_action"] == "Run one revised public fixture check" + assert journal["host_result"]["recommended_action"] != journal["host_result"]["next_action"] + assert "Run one revised public fixture check" not in state if result_kind != "validated_progress": assert f"LoopX%20Turn%20{result_kind}" in state From cfff78b193f9d7d1496e9e4e7a95c94d681574d1 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 4 Oct 2026 15:40:11 +0800 Subject: [PATCH 8/8] refactor(testing): isolate bounded read-fence migration policy Signed-off-by: huangruiteng --- .../testing/cli_output_differential.py | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index db1ba7112f..818c82d9fe 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -651,6 +651,30 @@ def _projection_envelope_migration( return {}, ["projection envelope schema coverage changed"], [] +def _task_step_read_fence_migration( + base: dict[str, Any], candidate: dict[str, Any], +) -> tuple[dict[Metric, int], list[str]]: + """Bound the first read-fence projection, not later growth or other views.""" + before = base.get("next_action_basis_count", 0) + after = candidate.get("next_action_basis_count", 0) + row_id = str(base["row_id"]) + if not (base["format"] == "json" + and row_id.startswith(("surface/status/", "surface/quota_should_run/", "variant/status_task_graph_detail/")) + and type(before) is int and before == 0 + and type(after) is int and 0 < after <= 4): + return {}, [] + # Each ordinary fence is 100 pretty / 94 compact chars. The unchanged + # two-peer detail fixture adds 457 / 346 chars including task/actor rows. + per_fence: dict[Metric, int] = ( + {"chars": 256, "utf8_bytes": 256, "lines": 8, "compact_payload_chars": 192} + if row_id.startswith("variant/status_task_graph_detail/") else + {"chars": 192, "utf8_bytes": 192, "lines": 6, "compact_payload_chars": 144} + ) + return {metric: value * after for metric, value in per_fence.items()}, [ + "task-bound recommendation read fences added; bounded one-time JSON growth", + ] + + def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]: row_id = str(base["row_id"]) failures: list[str] = [] @@ -690,19 +714,8 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A ) failures.extend(projection_failures) review_signals.extend(projection_signals) - # A first task-step read fence costs 94 compact chars per occurrence. Peer - # detail also adds its task/actor row. Qualify only the initial 0->N change - # on these JSON views; keep absolute caps and later N->N growth unchanged. - before_basis = base.get("next_action_basis_count", 0) - after_basis = candidate.get("next_action_basis_count", 0) - basis_migration = ( - output_format == "json" - and row_id.startswith(("surface/status/", "surface/quota_should_run/", "variant/status_task_graph_detail/")) - and type(before_basis) is int and before_basis == 0 - and type(after_basis) is int and 0 < after_basis <= 4 - ) - if basis_migration: - review_signals.append("task-bound recommendation read fences added; bounded one-time JSON growth") + fence_allowance, fence_signals = _task_step_read_fence_migration(base, candidate) + review_signals.extend(fence_signals) deltas: dict[str, int | None] = {} allowances: dict[str, int | None] = {} for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): @@ -740,11 +753,7 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A ), _schema_migration_growth_allowance(migration, metric), projection_allowance.get(metric, 0), - after_basis * ( - {"chars": 256, "utf8_bytes": 256, "lines": 8, "compact_payload_chars": 192} - if row_id.startswith("variant/status_task_graph_detail/") else - {"chars": 192, "utf8_bytes": 192, "lines": 6, "compact_payload_chars": 144} - )[metric] if basis_migration else 0, + fence_allowance.get(metric, 0), ) # Thin installed prompts contain bilingual lifecycle instructions. A # small character-level clarification can cost three bytes per CJK