diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 2f89f728..e8b77164 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -254,9 +254,14 @@ todo text. If a dashboard or controller needs the new checklist immediately, refresh the status projection after the write: ```bash -loopx refresh-state --goal-id +loopx refresh-state --goal-id --agent-id ``` +For multi-agent goals, `refresh-state` requires an explicit `--agent-id`. +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. + ## Lifecycle Contract Agents should not patch active-state checkboxes directly to move work forward. @@ -364,7 +369,9 @@ loopx refresh-state \ --goal-id \ --classification \ --delivery-batch-scale multi_surface \ - --delivery-outcome outcome_progress + --delivery-outcome outcome_progress \ + --agent-id \ + --progress-scope goal ``` This keeps validated side-agent product work from being misread as another diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 2c64c2fa..5709b782 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -635,8 +635,8 @@ When available, `quota should-run` also keeps next-action signals separate: recommendation, and `agent_lane_next_action` is the current `--agent-id` slice. 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 `refresh-state --next-action` or keep treating the -signals as distinct. +intended durable route with a primary goal-scope `refresh-state --next-action` +or keep treating the signals as distinct. `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 diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index 02e9e101..5f388616 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -986,18 +986,24 @@ run guidance from durable-state projection and last-resort compatibility fallback. `--recommended-action` describes the appended run record; it does not rewrite the active state's durable `## Next Action`. To intentionally change that -durable route, run `refresh-state --next-action `. Status -projections may expose both `active_state_next_action` and +durable route in a multi-agent goal, the primary agent must run +`refresh-state --agent-id --progress-scope goal --next-action +`. Status projections may expose both +`active_state_next_action` and `latest_run_recommended_action`; when they differ, `next_action_projection_warning` marks the drift instead of silently choosing one as the only truth. Executable dispatch should use `agent_lane_next_action` / todo projection rather than treating shared `## Next Action` as a per-agent work item. -When a refresh is scoped with `--agent-id`, the run records -`progress_scope=agent_lane`. This is a side-lane note, not a project-level -status transition: status/quota keep selecting the latest non-agent-lane run for -the goal-level `status` and `recommended_action`, while exposing the side-lane -note as `agent_lane_recommendation` on the attention item and project asset. +In a multi-agent goal, `refresh-state` requires an explicit `--agent-id`; text +or todo-title inference is not a valid identity source. When a refresh is +scoped with `--agent-id` and no `--progress-scope`, the run records +`progress_scope=agent_lane`. This is a lane note, not a project-level status +transition: status/quota keep selecting the latest non-agent-lane run for the +goal-level `status` and `recommended_action`, while exposing the lane note as +`agent_lane_recommendation` on the attention item and project asset. A +goal-level refresh in a multi-agent goal must use the primary agent with +`--progress-scope goal`. Use this for side agents that want to record their own lane recommendation without replacing the primary controller's next action. diff --git a/examples/heartbeat-prompt-smoke.py b/examples/heartbeat-prompt-smoke.py index 6afcf670..ca47af35 100644 --- a/examples/heartbeat-prompt-smoke.py +++ b/examples/heartbeat-prompt-smoke.py @@ -230,8 +230,8 @@ def main() -> int: "loopx todo add --goal-id public-heartbeat-goal --role user|agent", "blockers/plans, not prose", 'loopx --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', - "validated progress artifacts pass explicit `--delivery-batch-scale` and `--delivery-outcome`", - "Do not append spend for quiet skips", + "progress: `loopx refresh-state --goal-id public-heartbeat-goal", + "No spend for quiet skips", ): assert phrase in compact_task, phrase registry_default_task = normalized(str(registry_default_payload["task_body"])) diff --git a/examples/next-action-projection-contract-smoke.py b/examples/next-action-projection-contract-smoke.py index 00ac3a23..8b1443d7 100644 --- a/examples/next-action-projection-contract-smoke.py +++ b/examples/next-action-projection-contract-smoke.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import subprocess import sys import tempfile from pathlib import Path @@ -115,6 +116,21 @@ def collect_projection(registry_path: Path, runtime: Path, project: Path) -> dic 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[1], + text=True, + capture_output=True, + check=False, + ) + + def main() -> None: original_now_local = state_refresh.now_local try: @@ -130,6 +146,8 @@ def main() -> None: state_file=None, classification="state_refreshed", recommended_action=None, + agent_id="codex-main-control", + progress_scope="goal", dry_run=True, sync_global=False, ) @@ -147,6 +165,8 @@ def main() -> None: state_file=None, classification="state_refreshed", recommended_action=RUN_RECOMMENDATION, + agent_id="codex-main-control", + progress_scope="goal", dry_run=False, sync_global=False, ) @@ -176,6 +196,8 @@ def main() -> None: classification="state_refreshed", recommended_action=UPDATED_RUN_RECOMMENDATION, next_action=UPDATED_NEXT_ACTION, + agent_id="codex-main-control", + progress_scope="goal", dry_run=False, sync_global=False, ) @@ -210,6 +232,63 @@ def main() -> None: assert "active_state_next_action" in quota_markdown, quota_markdown assert "latest_run_recommended_action" 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), @@ -224,6 +303,8 @@ def main() -> None: state_file=None, classification="state_refreshed", recommended_action=None, + agent_id="codex-main-control", + progress_scope="goal", dry_run=True, sync_global=False, ) diff --git a/examples/refresh-state-agent-lane-scope-smoke.py b/examples/refresh-state-agent-lane-scope-smoke.py index 55ec7f1f..3d06d112 100644 --- a/examples/refresh-state-agent-lane-scope-smoke.py +++ b/examples/refresh-state-agent-lane-scope-smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Smoke-test agent-lane refreshes without stealing goal-level next action.""" +"""Smoke-test explicit refresh-state goal/agent-lane scoping.""" from __future__ import annotations @@ -19,6 +19,13 @@ PRIMARY_ACTION = "Run the primary benchmark bootstrap hardening slice." PRIMARY_AGENT_LANE_ACTION = "Continue the primary adapter lifecycle rollout repair." SIDE_ACTION = "Polish the hosted frontstage showcase for external developers." +SIDE_HANDOFF_ACTION = ( + "Primary should inspect todo_primary while side lane switches to the next product todo." +) +AUTO_RESEARCH_ACTION = ( + "[P0-auto-research] Use rollout-backed research_evidence_graph_v0 to generate " + "live promotion and retirement candidates." +) def write_fixture(root: Path) -> tuple[Path, Path, Path]: @@ -44,6 +51,9 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: f"- [ ] [P1] {SIDE_ACTION}\n" " \n\n" + f"- [ ] {AUTO_RESEARCH_ACTION}\n" + " \n\n" "## Next Action\n\n" f"- {PRIMARY_ACTION}\n", encoding="utf-8", @@ -65,7 +75,10 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: "adapter": {"kind": "fixture", "status": "connected-read-only"}, "coordination": { "primary_agent": "codex-main-control", - "registered_agents": ["codex-main-control", "codex-side-bypass"], + "registered_agents": [ + "codex-main-control", + "codex-side-bypass", + ], }, "authority_sources": [], } @@ -80,14 +93,24 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: return registry_path, runtime, project +def expect_value_error(message: str, callback) -> None: + error = None + try: + callback() + except ValueError as exc: + error = str(exc) + assert error and message in error, error + + def main() -> None: original_now_local = state_refresh.now_local try: with tempfile.TemporaryDirectory(prefix="loopx-agent-lane-refresh-") as raw_tmp: registry_path, runtime, project = write_fixture(Path(raw_tmp)) + state_path = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" state_refresh.now_local = lambda: "2026-06-20T00:00:00+00:00" - state_refresh.refresh_state_run( + primary_payload = state_refresh.refresh_state_run( registry_path=registry_path, runtime_root_override=str(runtime), goal_id=GOAL_ID, @@ -97,9 +120,78 @@ def main() -> None: recommended_action=PRIMARY_ACTION, delivery_batch_scale="multi_surface", delivery_outcome="outcome_progress", + agent_id="codex-main-control", + progress_scope="goal", dry_run=False, sync_global=False, ) + assert primary_payload["progress_scope"] == "goal", primary_payload + assert primary_payload["agent_id"] == "codex-main-control", primary_payload + assert primary_payload.get("agent_lane") is None, primary_payload + + state_path.write_text( + state_path.read_text(encoding="utf-8").replace( + "updated_at: 2026-06-20T00:00:00+00:00", + "updated_at: 2026-06-20T00:00:30+00:00", + 1, + ), + encoding="utf-8", + ) + + expect_value_error( + "multi-agent refresh-state requires --agent-id", + 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_unscoped", + recommended_action=f"Continue todo_side: {SIDE_ACTION}", + delivery_batch_scale="single_surface", + delivery_outcome="outcome_progress", + dry_run=True, + sync_global=False, + ), + ) + + expect_value_error( + "agent-lane refresh-state cannot update the durable active-state 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, + ), + ) + + expect_value_error( + "goal-scope refresh-state requires the primary agent", + 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_goal_scope", + recommended_action=SIDE_ACTION, + delivery_batch_scale="single_surface", + delivery_outcome="outcome_progress", + agent_id="codex-side-bypass", + progress_scope="goal", + dry_run=True, + sync_global=False, + ), + ) state_refresh.now_local = lambda: "2026-06-20T00:01:00+00:00" side_payload = state_refresh.refresh_state_run( @@ -119,6 +211,28 @@ def main() -> None: ) assert side_payload["progress_scope"] == "agent_lane", side_payload assert side_payload["agent_id"] == "codex-side-bypass", side_payload + assert side_payload["agent_lane"] == "productization_frontstage", side_payload + assert "agent_lane_scope_inference" not in side_payload, side_payload + + state_refresh.now_local = lambda: "2026-06-20T00:03:00+00:00" + handoff_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="side_lane_review_handoff", + recommended_action=SIDE_HANDOFF_ACTION, + delivery_batch_scale="single_surface", + delivery_outcome="outcome_progress", + agent_id="codex-side-bypass", + dry_run=False, + sync_global=False, + ) + assert handoff_payload["progress_scope"] == "agent_lane", handoff_payload + assert handoff_payload["agent_id"] == "codex-side-bypass", handoff_payload + assert handoff_payload["agent_lane"] == "codex-side-bypass", handoff_payload + assert "agent_lane_scope_inference" not in handoff_payload, handoff_payload history = collect_history( registry_path=registry_path, @@ -127,7 +241,7 @@ def main() -> None: limit=5, ) goal = history["goals"][0] - assert goal["latest_runs"][0]["classification"] == "frontstage_side_lane_next", goal + assert goal["latest_runs"][0]["classification"] == "side_lane_review_handoff", goal assert goal["latest_status_run"]["classification"] == "terminal_bench_primary_ready", goal status = collect_status( @@ -142,57 +256,79 @@ def main() -> None: assert item["recommended_action"] == PRIMARY_ACTION, item assert item["latest_run_recommended_action"] == PRIMARY_ACTION, item assert item["latest_run_recommended_action_source"] == "latest_status_run", item + assert "stale_latest_run_warning" not in item, item + assert "stale_latest_run_warning" not in item["project_asset"], item lane = item["agent_lane_recommendation"] assert lane["progress_scope"] == "agent_lane", lane assert lane["agent_id"] == "codex-side-bypass", lane - assert lane["agent_lane"] == "productization_frontstage", lane - assert lane["recommended_action"] == SIDE_ACTION, lane + assert lane["agent_lane"] == "codex-side-bypass", lane + assert lane["recommended_action"] == SIDE_HANDOFF_ACTION, lane assert item["project_asset"]["agent_lane_recommendation"] == lane, item - state_refresh.now_local = lambda: "2026-06-20T00:02:00+00:00" - primary_lane_payload = state_refresh.refresh_state_run( + expect_value_error( + "agent-lane refresh-state cannot update the durable active-state 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, + ), + ) + + state_refresh.now_local = lambda: "2026-06-20T00:04:00+00:00" + primary_next_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="adapter_lifecycle_primary_lane_next", + classification="adapter_lifecycle_primary_goal_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", - agent_lane="adapter_lifecycle_rollout", + progress_scope="goal", dry_run=False, sync_global=False, ) - assert primary_lane_payload["progress_scope"] == "agent_lane", primary_lane_payload - assert primary_lane_payload["agent_id"] == "codex-main-control", primary_lane_payload + 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 - primary_lane_status = collect_status( + primary_goal_status = collect_status( registry_path=registry_path, runtime_root_override=str(runtime), scan_roots=[project], limit=5, ) - primary_lane_item = next( + primary_goal_item = next( item - for item in primary_lane_status["attention_queue"]["items"] + for item in primary_goal_status["attention_queue"]["items"] if item["goal_id"] == GOAL_ID ) - assert primary_lane_item["status"] == "terminal_bench_primary_ready", primary_lane_item - assert primary_lane_item["recommended_action"] == PRIMARY_ACTION, primary_lane_item - assert ( - primary_lane_item["active_state_next_action"] == PRIMARY_AGENT_LANE_ACTION - ), primary_lane_item + 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_lane_item["latest_run_recommended_action"] == PRIMARY_AGENT_LANE_ACTION - ), primary_lane_item + primary_goal_item["latest_run_recommended_action"] + == PRIMARY_AGENT_LANE_ACTION + ), primary_goal_item assert ( - primary_lane_item["latest_run_recommended_action_source"] - == "agent_lane_recommendation" - ), primary_lane_item - assert "next_action_projection_warning" not in primary_lane_item, primary_lane_item + 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 diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 0c442e92..e59f75e2 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -21,6 +21,7 @@ from ..state_refresh import ( DEFAULT_REFRESH_ACTION, DEFAULT_REFRESH_CLASSIFICATION, + PROGRESS_SCOPE_CHOICES, REPAIR_DELTA_KIND_CHOICES, refresh_state_run, render_state_refresh_markdown, @@ -118,6 +119,14 @@ def register_project_lifecycle_commands( "--agent-lane", help="Public-safe lane label for --agent-id scoped refreshes, such as productization_frontstage.", ) + refresh_state_parser.add_argument( + "--progress-scope", + choices=PROGRESS_SCOPE_CHOICES, + help=( + "Refresh scope. In multi-agent goals, use agent_lane for per-agent runnable " + "status, or goal with the primary agent for durable goal-level status/Next Action." + ), + ) refresh_state_parser.add_argument( "--dry-run", action="store_true", @@ -284,6 +293,7 @@ def handle_project_lifecycle_command( delivery_outcome=args.delivery_outcome, agent_id=args.agent_id, agent_lane=args.agent_lane, + progress_scope=args.progress_scope, autonomous_replan_recorded=bool(args.autonomous_replan_recorded), repair_delta_kinds=args.repair_delta_kinds, dry_run=bool(args.dry_run), diff --git a/loopx/heartbeat_prompt.py b/loopx/heartbeat_prompt.py index 7c8afa90..9b04fbd8 100644 --- a/loopx/heartbeat_prompt.py +++ b/loopx/heartbeat_prompt.py @@ -5,7 +5,12 @@ from typing import Any from .agent_registry import normalize_registered_agents -from .project_prompt import render_cli_preflight, render_quota_guard_command, render_quota_spend_command +from .project_prompt import ( + render_cli_preflight, + render_quota_guard_command, + render_quota_spend_command, + render_refresh_state_command, +) from .todo_contract import normalize_todo_claimed_by @@ -423,6 +428,19 @@ def build_heartbeat_prompt( cli_bin=cli_bin, agent_id=normalized_agent_id, ) + refresh_state_command = render_refresh_state_command( + goal_id, + cli_bin=cli_bin, + agent_id=normalized_agent_id, + ) + progress_refresh_state_command = render_refresh_state_command( + goal_id, + cli_bin=cli_bin, + agent_id=normalized_agent_id, + classification="", + delivery_batch_scale="multi_surface", + delivery_outcome="outcome_progress", + ) cli_preflight = render_cli_preflight(cli_bin=cli_bin) expanded_prompt_command = f"{cli_bin} heartbeat-prompt --goal-id {goal_id}{active_state_arg}{agent_args}" compact_prompt_command = f"{cli_bin} heartbeat-prompt --compact --goal-id {goal_id}{active_state_arg}{agent_args}" @@ -442,6 +460,8 @@ def build_heartbeat_prompt( cli_preflight=cli_preflight, quota_guard_command=quota_guard_command, quota_spend_command=quota_spend_command, + refresh_state_command=refresh_state_command, + progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=resolved_material_rule, permission_rule=resolved_permission_rule, cli_bin=cli_bin, @@ -475,9 +495,11 @@ def build_heartbeat_prompt( "compact_prompt_command": compact_prompt_command, "brief_prompt_command": brief_prompt_command, "thin_prompt_command": thin_prompt_command, - "quota_guard_command": quota_guard_command, - "quota_spend_command": quota_spend_command, - "cli_preflight": cli_preflight, + "quota_guard_command": quota_guard_command, + "quota_spend_command": quota_spend_command, + "refresh_state_command": refresh_state_command, + "progress_refresh_state_command": progress_refresh_state_command, + "cli_preflight": cli_preflight, "material_queue_rule": resolved_material_rule, "permission_rule": resolved_permission_rule, "interface_budget": build_interface_budget( @@ -568,6 +590,8 @@ def render_heartbeat_task_body( cli_preflight: str, quota_guard_command: str, quota_spend_command: str, + refresh_state_command: str, + progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, cli_bin: str, @@ -745,7 +769,7 @@ def render_heartbeat_task_body( 9. If the dashboard or controller needs state after spend, refresh: ```bash - {cli_bin} refresh-state --goal-id {goal_id} + {refresh_state_command} ``` For a validated progress artifact, add a public-safe classification and @@ -753,7 +777,7 @@ def render_heartbeat_task_body( names: ```bash - {cli_bin} refresh-state --goal-id {goal_id} --classification --delivery-batch-scale multi_surface --delivery-outcome outcome_progress + {progress_refresh_state_command} ``` 10. Return a compact final report. Use heartbeat `NOTIFY` only for meaningful @@ -771,6 +795,8 @@ def render_brief_heartbeat_task_body( cli_preflight: str, quota_guard_command: str, quota_spend_command: str, + refresh_state_command: str, + progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, cli_bin: str, @@ -837,6 +863,8 @@ def render_compact_heartbeat_task_body( cli_preflight: str, quota_guard_command: str, quota_spend_command: str, + refresh_state_command: str, + progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, cli_bin: str, @@ -854,20 +882,19 @@ def render_compact_heartbeat_task_body( Expanded lifecycle contract: `{expanded_prompt_command}`. {scope_block} -Before delivery, make CLI reachable; run quota guard: +Before delivery, make CLI reachable; run guard: ```bash {cli_preflight} {quota_guard_command} ``` -If preflight fails: quiet `DONT_NOTIFY`; no work/spend. +Preflight fail: quiet `DONT_NOTIFY`; no work/spend. If `should_run=false`: `monitor_quiet_skip` appends at most one no-spend -`quota monitor-poll --execute` event, reruns the guard, then stays quiet unless -the next guard exposes `autonomous_replan_required` / `must_attempt_work=true`; -no delivery edits/spend; unchanged monitor-only polls are not self-stop -signals. +`quota monitor-poll --execute`, reruns guard, then stays quiet unless +`autonomous_replan_required` / `must_attempt_work=true`; no edits/spend; +unchanged monitor-only polls are not self-stop signals. `state=operator_gate` or `notify_user_on_open_todo=true`: blocker-push; `open_todo_notification_policy=repeat_until_resolved`: repeat `NOTIFY`; if action/open todo exists, list payload todo(s)/questions, never only @@ -882,7 +909,7 @@ def render_compact_heartbeat_task_body( Legacy/raw fallback is not owner/gate/stop authority. Treat `run_history.latest_runs` as drill-down only. 2. Stop only for this goal's own blocker todo: Chinese `NOTIFY`, no work/spend. - Dependency/sibling todos: record/surface; continue audit. + Dependency/sibling todos: surface; continue audit. 3. If `effective_action=outcome_floor_recovery` or `recovery_delivery_allowed=true` or `safe_bypass_kind=outcome_floor_recovery`, run only ranker/cross-domain @@ -908,26 +935,23 @@ def render_compact_heartbeat_task_body( no-spend stall evidence, so if 2 eligible heartbeats only repeat status/brief checks with no artifact/progress/gate/validation, replan before quiet no-op. Pause/delete only if repair is stuck for 2 more turns. -7. Choose one bounded segment. Coherent batch is OK with clear validation. - Public-safe commit/push/PR may proceed after validation/clean scan. Stop - for private/company material, credentials, destructive git, production, or - explicit review rules. +7. Choose one bounded segment; coherent batch is OK with clear validation. + Public-safe commit/push/PR may proceed after validation/clean scan. Stop for + private/company material, credentials, destructive git, production, or review rules. 8. Validate; write files/validation/critic/next action to active state; use `{cli_bin} todo add --goal-id {goal_id} --role user|agent` for blockers/plans, not prose. Nontrivial done -> successor todo or no-follow-up rationale. -9. After completed delivery or safe-bypass work, spend once before state - refresh: +9. After delivery/safe-bypass, spend once before refresh: ```bash {quota_spend_command} ``` -10. Refresh after spend if needed; validated progress artifacts pass explicit - `--delivery-batch-scale` and `--delivery-outcome`. +10. Refresh after spend if needed; progress: `{progress_refresh_state_command}`. -Do not append spend for quiet skips, preflight failures, blocker-push asks, -pure dry-runs, self-cancel turns, or duplicate accounting attempts. +No spend for quiet skips, preflight failures, blocker-push asks, dry-runs, +self-cancel turns, or duplicate accounting. Return compactly. Use heartbeat `NOTIFY` only for committed artifact, user gate, real blocker, or self-stop; otherwise use `DONT_NOTIFY`. @@ -943,6 +967,8 @@ def render_thin_heartbeat_task_body( cli_preflight: str, quota_guard_command: str, quota_spend_command: str, + refresh_state_command: str, + progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, cli_bin: str, diff --git a/loopx/project_prompt.py b/loopx/project_prompt.py index 181885cf..c1c461c2 100644 --- a/loopx/project_prompt.py +++ b/loopx/project_prompt.py @@ -20,6 +20,13 @@ def shell_arg(value: str) -> str: return shlex.quote(value) +def shell_arg_or_placeholder(value: str) -> str: + text = str(value) + if text.startswith("<") and text.endswith(">"): + return text + return shell_arg(text) + + def render_cli_preflight(*, cli_bin: str = "loopx") -> str: cli_bin_arg = shell_arg(cli_bin) return f"""export PATH="$HOME/.local/bin:$PATH" @@ -77,6 +84,39 @@ def render_quota_spend_command( ) +def render_refresh_state_command( + goal_id: str, + *, + cli_bin: str = "loopx", + agent_id: str | None = None, + progress_scope: str | None = None, + classification: str | None = None, + delivery_batch_scale: str | None = None, + delivery_outcome: str | None = None, +) -> str: + agent_arg = f" --agent-id {shell_arg(agent_id)}" if agent_id else "" + scope_arg = f" --progress-scope {shell_arg(progress_scope)}" if progress_scope else "" + classification_arg = ( + f" --classification {shell_arg_or_placeholder(classification)}" + if classification + else "" + ) + scale_arg = ( + f" --delivery-batch-scale {shell_arg_or_placeholder(delivery_batch_scale)}" + if delivery_batch_scale + else "" + ) + outcome_arg = ( + f" --delivery-outcome {shell_arg_or_placeholder(delivery_outcome)}" + if delivery_outcome + else "" + ) + return ( + f"{shell_arg(cli_bin)} refresh-state --goal-id {shell_arg(goal_id)}" + f"{classification_arg}{scale_arg}{outcome_arg}{agent_arg}{scope_arg}" + ) + + def render_heartbeat_prompt_command( goal_id: str, *, @@ -187,6 +227,7 @@ def build_new_project_prompt( ) quota_guard_command = render_quota_guard_command(resolved_goal_id) quota_spend_command = render_quota_spend_command(resolved_goal_id) + refresh_command = render_refresh_state_command(resolved_goal_id) prompt = render_prompt_text( project=project_text, goal_doc=goal_doc_text, @@ -200,6 +241,7 @@ def build_new_project_prompt( connect_command=connect_command, quota_guard_command=quota_guard_command, quota_spend_command=quota_spend_command, + refresh_command=refresh_command, cli_bin="loopx", spawn_allowed=spawn_allowed, allowed_domains=allowed_domains, @@ -221,6 +263,7 @@ def build_new_project_prompt( "connect_command": connect_command, "quota_guard_command": quota_guard_command, "quota_spend_command": quota_spend_command, + "refresh_command": refresh_command, "cli_preflight": render_cli_preflight(), "prompt": prompt, } @@ -280,7 +323,11 @@ def build_codex_cli_bootstrap_message( agent_id=agent_id, ) install_repair_command = render_codex_cli_no_clone_preflight(cli_bin=cli_bin) - refresh_command = f"{shell_arg(cli_bin)} refresh-state --goal-id {shell_arg(resolved_goal_id)}" + refresh_command = render_refresh_state_command( + resolved_goal_id, + cli_bin=cli_bin, + agent_id=agent_id, + ) first_run_validation_checklist = [ f"{cli_bin} doctor passed after no-clone install repair or existing install", "repo bootstrap/connect completed conservatively or a concrete install/connect blocker was shown", @@ -593,6 +640,7 @@ def render_prompt_text( connect_command: str, quota_guard_command: str, quota_spend_command: str, + refresh_command: str, cli_bin: str, spawn_allowed: bool, allowed_domains: list[str], @@ -660,7 +708,7 @@ def render_prompt_text( - 是否 `autonomous=yes`,允许你在 quota guard 通过后开始执行第一个接受的 agent todo。 如果用户接受候选 todo,用输出里的 `{cli_bin} todo add ...` 命令写入 agent todo; 如果用户允许自主推进,先运行 quota guard,再执行第一个已接受 agent todo。 - 如果用户不允许自主推进,只写入接受的 todo 并运行 `{cli_bin} refresh-state --goal-id {goal_id}`, + 如果用户不允许自主推进,只写入接受的 todo 并运行 `{refresh_command}`, 然后停下来汇报。 如果目标状态包含私有证据,把 `.loopx/` 和 `.codex/goals/` 加入该项目 `.gitignore`。 `{cli_bin} connect` 默认会同步到共享全局 registry;不要手动编辑其他项目的 registry。 @@ -712,7 +760,7 @@ def render_prompt_text( ``` agent 自己的后续动作写成 `--role agent`。写入后如果 dashboard 需要看到最新状态,运行 - `{cli_bin} refresh-state --goal-id {goal_id}`。 + `{refresh_command}`。 完整契约见 LoopX 仓库里的 `docs/project-agent-todo-contract.md`。 6. 如果需要把当前 packet 或已批准命令交给项目 agent,优先生成最小 handoff,不要从旧聊天、 旧 review packet 或 `run_history.latest_runs` 拼当前状态。当前权威状态来自 @@ -724,7 +772,7 @@ def render_prompt_text( ``` 只把输出的 handoff 交给目标项目 agent;完整 review packet 留给 operator view / evidence drill-down。 -7. 如果要给这个项目设置 recurring Codex App heartbeat,初始可用每 3 分钟一次,后续跟随 `quota should-run.scheduler_hint` 降频;不要手抄 guard 和 spend 协议;先生成 task body,再把输出复制进 automation: +7. 如果要给这个项目设置 recurring Codex App heartbeat,默认每 3 分钟一次,后续跟随 `quota should-run.scheduler_hint` 降频;不要手抄 guard 和 spend 协议;先生成 task body,再把输出复制进 automation: ```bash {cli_bin} heartbeat-prompt --goal-id {goal_id} --active-state .codex/goals/{goal_id}/ACTIVE_GOAL_STATE.md @@ -739,7 +787,7 @@ def render_prompt_text( 9. 如果本轮只更新了 active state、ledger 或外部规划文档,没有产生新的 adapter run,或者 dashboard 仍显示旧 run,追加一个 state-only refresh run;若本轮实际消耗了 automatic delivery compute,则把这个 refresh 放到 quota spend 之后,避免 state refresh 先关闭 active delivery lane: ```bash -{cli_bin} refresh-state --goal-id {goal_id} +{refresh_command} ``` 这个命令也会自动同步全局 registry。 diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 323cd627..bdab4727 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -23,6 +23,7 @@ TODO_TASK_CLASS_BLOCKER, TODO_TASK_CLASS_MONITOR, TODO_TASK_CLASS_USER_GATE, + normalize_todo_claimed_by, ) @@ -32,7 +33,9 @@ RECOMMENDED_ACTION_SOURCE_ACTIVE_NEXT_ACTION = "active_state_next_action" RECOMMENDED_ACTION_SOURCE_AGENT_TODO_FALLBACK = "agent_todo_fallback" RECOMMENDED_ACTION_SOURCE_DEFAULT = "default_refresh_action" +GOAL_PROGRESS_SCOPE = "goal" AGENT_LANE_PROGRESS_SCOPE = "agent_lane" +PROGRESS_SCOPE_CHOICES = (GOAL_PROGRESS_SCOPE, AGENT_LANE_PROGRESS_SCOPE) RECOMMENDED_ACTION_SECTION_LINE_LIMIT = 16 BULLET_PREFIX_RE = re.compile(r"^(?:[-*]\s+|\d+[.)]\s+)") CHECKBOX_PREFIX_RE = re.compile(r"^\[(?P[ xX])\]\s+") @@ -142,6 +145,44 @@ def normalize_repair_delta_kinds(values: list[str] | None) -> list[str]: return normalized +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 + + +def primary_agent_for_goal(registry_goal: dict[str, Any] | None) -> str | None: + coordination = ( + registry_goal.get("coordination") + if registry_goal and isinstance(registry_goal.get("coordination"), dict) + else {} + ) + return normalize_todo_claimed_by(coordination.get("primary_agent") if coordination else None) + + +def normalize_progress_scope(value: str | None) -> str: + normalized = str(value or "").strip() + if not normalized: + return "" + validate_public_safe_text("progress_scope", normalized) + if normalized not in PROGRESS_SCOPE_CHOICES: + raise ValueError( + "--progress-scope must be one of: " + ", ".join(PROGRESS_SCOPE_CHOICES) + ) + return normalized + + def _noop_classification_for(classification: str) -> str: normalized = str(classification or "").strip().lower() if "repair" in normalized and "replan" not in normalized: @@ -401,6 +442,7 @@ def build_state_refresh_record( registry_goal: dict[str, Any] | None, delivery_batch_scale: str | None = None, delivery_outcome: str | None = None, + progress_scope: str | None = None, agent_id: str | None = None, agent_lane: str | None = None, autonomous_replan_recorded: bool = False, @@ -455,9 +497,11 @@ def build_state_refresh_record( } if repair_delta_contract: record["autonomous_replan_ack"]["delta_contract"] = repair_delta_contract + if progress_scope: + record["progress_scope"] = progress_scope if agent_id: - record["progress_scope"] = AGENT_LANE_PROGRESS_SCOPE record["agent_id"] = agent_id + if progress_scope == AGENT_LANE_PROGRESS_SCOPE and agent_id: record["agent_lane"] = agent_lane or agent_id return record @@ -598,6 +642,7 @@ def refresh_state_run( delivery_outcome: str | None = None, agent_id: str | None = None, agent_lane: str | None = None, + progress_scope: str | None = None, autonomous_replan_recorded: bool = False, repair_delta_kinds: list[str] | None = None, dry_run: bool, @@ -613,6 +658,7 @@ def refresh_state_run( validate_public_safe_text("agent_lane", normalized_agent_lane) if normalized_agent_lane and not normalized_agent_id: raise ValueError("--agent-lane requires --agent-id so the lane has an owner") + normalized_progress_scope = normalize_progress_scope(progress_scope) normalized_delivery_batch_scale = ( require_delivery_batch_scale(delivery_batch_scale).value if delivery_batch_scale else None ) @@ -628,26 +674,45 @@ def refresh_state_run( project_override=project, state_file_override=state_file, ) - if normalized_agent_id and registry_goal: - coordination = registry_goal.get("coordination") if isinstance(registry_goal.get("coordination"), dict) else {} - registered_raw = coordination.get("registered_agents") if isinstance(coordination, dict) else [] - registered_agents = [] - registered_values = registered_raw if isinstance(registered_raw, list) else [] - for value in registered_values: - if isinstance(value, dict): - registered_agents.append(str(value.get("id") or "")) - else: - registered_agents.append(str(value or "")) - registered_agents = [value for value in registered_agents if value] - if registered_agents and normalized_agent_id not in registered_agents: - raise ValueError( - f"agent_id {normalized_agent_id!r} is not registered for goal {safe_goal_id!r}" - ) if not resolved_state_file.exists(): raise FileNotFoundError(f"state file does not exist: {resolved_state_file}") state_text = resolved_state_file.read_text(encoding="utf-8") expected_write_state_text = state_text normalized_next_action = normalize_next_action_text(next_action) if next_action else None + registered_agents = registered_agents_for_goal(registry_goal) + primary_agent = primary_agent_for_goal(registry_goal) + known_agents = {agent for agent in registered_agents if agent} + if primary_agent: + known_agents.add(primary_agent) + multi_agent_goal = len(known_agents) > 1 + if normalized_agent_id and known_agents and normalized_agent_id not in known_agents: + raise ValueError( + f"agent_id {normalized_agent_id!r} is not registered for goal {safe_goal_id!r}" + ) + if multi_agent_goal and not normalized_agent_id: + raise ValueError( + "multi-agent refresh-state requires --agent-id; text inference is disabled" + ) + if not normalized_progress_scope: + normalized_progress_scope = ( + AGENT_LANE_PROGRESS_SCOPE if normalized_agent_id else GOAL_PROGRESS_SCOPE + ) + 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 with the primary agent" + ) + if normalized_progress_scope == GOAL_PROGRESS_SCOPE: + if normalized_agent_lane: + raise ValueError("--agent-lane requires --progress-scope agent_lane") + if primary_agent and normalized_agent_id and normalized_agent_id != primary_agent: + raise ValueError( + "goal-scope refresh-state requires the primary agent " + f"{primary_agent!r}; got {normalized_agent_id!r}" + ) generated_at = now_local() active_state_next_action_update: dict[str, Any] | None = None if normalized_next_action: @@ -703,6 +768,7 @@ def refresh_state_run( registry_goal=registry_goal, delivery_batch_scale=normalized_delivery_batch_scale, delivery_outcome=normalized_delivery_outcome, + progress_scope=normalized_progress_scope, agent_id=normalized_agent_id or None, agent_lane=normalized_agent_lane or None, autonomous_replan_recorded=effective_autonomous_replan_recorded, @@ -741,6 +807,18 @@ def refresh_state_run( "json_path": str(json_path), "markdown_path": str(markdown_path), } + record_state = record.get("state") if isinstance(record.get("state"), dict) else {} + record_frontmatter = ( + record_state.get("frontmatter") + if isinstance(record_state.get("frontmatter"), dict) + else {} + ) + index_record["state"] = { + "sha256_16": record_state.get("sha256_16"), + "frontmatter": { + "updated_at": record_frontmatter.get("updated_at"), + }, + } if normalized_delivery_batch_scale: index_record["delivery_batch_scale"] = normalized_delivery_batch_scale if normalized_delivery_outcome: @@ -749,9 +827,11 @@ def refresh_state_run( index_record["autonomous_replan_ack"] = record["autonomous_replan_ack"] if requested_classification != classification: index_record["requested_classification"] = requested_classification + if normalized_progress_scope: + index_record["progress_scope"] = normalized_progress_scope if normalized_agent_id: - index_record["progress_scope"] = AGENT_LANE_PROGRESS_SCOPE index_record["agent_id"] = normalized_agent_id + if normalized_progress_scope == AGENT_LANE_PROGRESS_SCOPE and normalized_agent_id: index_record["agent_lane"] = normalized_agent_lane or normalized_agent_id payload = { "ok": True, diff --git a/loopx/status.py b/loopx/status.py index 72e991c3..bae51c9a 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -7041,6 +7041,27 @@ def active_state_projection_warning(goal: dict[str, Any], current_run: dict[str, digest_mismatch = bool(run_state_digest and active_digest != run_state_digest) if not (active_newer_than_run_state or active_newer_than_run or digest_mismatch): return None + for run in goal.get("latest_runs") if isinstance(goal.get("latest_runs"), list) else []: + if not isinstance(run, dict): + continue + if str(run.get("progress_scope") or "") != AGENT_LANE_PROGRESS_SCOPE: + continue + agent_run_state = run.get("state") if isinstance(run.get("state"), dict) else {} + agent_run_frontmatter = ( + agent_run_state.get("frontmatter") + if isinstance(agent_run_state.get("frontmatter"), dict) + else {} + ) + agent_run_digest = str(agent_run_state.get("sha256_16") or "") + agent_run_updated_at = agent_run_frontmatter.get("updated_at") + agent_run_state_dt = parse_timestamp(agent_run_updated_at) + agent_run_generated_dt = parse_timestamp(str(run.get("generated_at") or "")) + if agent_run_digest and agent_run_digest == active_digest: + return None + if active_dt and agent_run_state_dt and active_dt <= agent_run_state_dt: + return None + if active_dt and not agent_run_state_dt and agent_run_generated_dt and active_dt <= agent_run_generated_dt: + return None reasons: list[str] = [] if active_newer_than_run: diff --git a/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index 09d87058..28d48906 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -598,9 +598,14 @@ or external coordination state without producing a new adapter run, append a state-only refresh: ```bash -loopx refresh-state --goal-id +loopx refresh-state --goal-id --agent-id ``` +For multi-agent goals, keep the same `--agent-id` envelope that passed +`quota should-run`. This default is an agent-lane refresh. To update the +goal-level route or durable `## Next Action`, the primary agent must add +`--progress-scope goal`. + If that refresh records a validated progress artifact rather than a pure state-only note, include a public-safe classification and explicit delivery hints so status, review packets, and quota guards do not infer scale/outcome @@ -611,7 +616,9 @@ loopx refresh-state \ --goal-id \ --classification \ --delivery-batch-scale multi_surface \ - --delivery-outcome outcome_progress + --delivery-outcome outcome_progress \ + --agent-id \ + --progress-scope goal ``` Use `delivery_outcome` as a machine enum, not as prose: