From 3e1890dd825b4c4f37a65bed3bf4ea334dabf53f Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 10:35:08 +0800 Subject: [PATCH 1/6] feat(semantics): separate action domains and verify production evidence Signed-off-by: song --- .github/workflows/full-public-smokes.yml | 3 + docs/reference/glossary.md | 246 ++++++++++ ...scope-projection-characterization-smoke.py | 6 +- .../control_plane/work-lane-contract-smoke.py | 13 +- examples/semantic-vocabulary-drift-smoke.py | 271 +++++++++-- loopx/cli_commands/quota.py | 7 +- .../cli_commands/quota_scheduler_followup.py | 3 +- loopx/cli_commands/turn.py | 3 +- .../agents/agent_scope_frontier.generated.ts | 17 + .../agents/agent_scope_frontier.py | 17 +- loopx/control_plane/effect_program.py | 10 +- loopx/control_plane/effect_program.ts | 21 +- loopx/control_plane/quota/decision_summary.py | 38 +- .../quota/effective_action.generated.ts | 76 +++ loopx/control_plane/quota/effective_action.py | 45 ++ .../control_plane/quota/heartbeat_receipt.py | 3 +- .../control_plane/quota/host_poll_receipts.py | 3 +- loopx/control_plane/quota/live_decision.py | 3 +- .../quota/monitor_poll_commit.ts | 11 +- .../control_plane/quota/projection_repair.py | 5 +- loopx/control_plane/quota/settlement_cli.py | 3 +- .../quota/settlement_precedence.py | 5 +- loopx/control_plane/quota/should_run.py | 5 +- .../control_plane/quota/should_run_packet.py | 21 +- loopx/control_plane/quota/slot_accounting.py | 13 +- loopx/control_plane/quota/spend_commit.ts | 21 +- loopx/control_plane/quota/stall_repair.py | 7 +- .../control_plane/quota/task_orchestration.py | 6 +- loopx/control_plane/quota/turn_envelope.ts | 3 +- .../quota/unsettled_host_turn.py | 3 +- .../scheduler/automation_liveness.py | 11 +- .../testing/action_portfolio_scenarios.py | 3 +- .../testing/host_prompt_behavior.py | 6 +- .../replan_semantic_action_behavior.py | 5 +- loopx/control_plane/todos/decision_scope.py | 5 +- loopx/control_plane/todos/user_gate.py | 5 +- loopx/control_plane/turn_driver/driver.py | 3 +- .../turn_driver/host_todo_completion.ts | 3 +- .../turn_driver/loop_controller.py | 5 +- .../control_plane/turn_driver/turn_journal.ts | 10 +- .../work_items/goal_route_hint.py | 7 +- .../work_items/interaction_contract.py | 30 +- loopx/ready_score.py | 3 +- loopx/semantics/inventory_v0.json | 16 +- loopx/semantics/production.py | 219 +++++++++ loopx/semantics/python_production.py | 436 ++++++++++++++++++ loopx/semantics/vocabulary_v0.json | 252 +++++++--- scripts/generate_semantic_bindings.py | 126 +++++ scripts/generate_semantic_inventory.py | 6 +- scripts/semantic_production_scan.mjs | 100 ++++ tests/architecture/test_semantic_bindings.py | 152 ++++++ .../architecture/test_semantic_production.py | 263 +++++++++++ .../test_semantic_python_production.py | 278 +++++++++++ .../test_semantic_vocabulary_drift.py | 166 +++++++ .../test_agent_scope_frontier_contract.py | 200 ++++++++ .../test_effect_interpreter_packet.py | 148 ++++++ .../test_frontier_envelope_compatibility.py | 193 ++++++++ .../test_quota_settlement_cli.py | 4 + .../test_user_gate_lane_progress.py | 27 ++ tests/control_plane_ts/effect_program.test.ts | 70 +++ tests/control_plane_ts/turn_journal.test.ts | 6 +- .../fixtures/turn_envelope_state_matrix.json | 4 +- tests/test_loopx_turn_executor.py | 14 + 63 files changed, 3422 insertions(+), 242 deletions(-) create mode 100644 docs/reference/glossary.md create mode 100644 loopx/control_plane/agents/agent_scope_frontier.generated.ts create mode 100644 loopx/control_plane/quota/effective_action.generated.ts create mode 100644 loopx/control_plane/quota/effective_action.py create mode 100644 loopx/semantics/production.py create mode 100644 loopx/semantics/python_production.py create mode 100644 scripts/generate_semantic_bindings.py create mode 100644 scripts/semantic_production_scan.mjs create mode 100644 tests/architecture/test_semantic_bindings.py create mode 100644 tests/architecture/test_semantic_production.py create mode 100644 tests/architecture/test_semantic_python_production.py create mode 100644 tests/control_plane/test_agent_scope_frontier_contract.py create mode 100644 tests/control_plane/test_frontier_envelope_compatibility.py diff --git a/.github/workflows/full-public-smokes.yml b/.github/workflows/full-public-smokes.yml index 2f990774fd..48a1971a4b 100644 --- a/.github/workflows/full-public-smokes.yml +++ b/.github/workflows/full-public-smokes.yml @@ -73,6 +73,9 @@ jobs: - name: Install smoke runtime dependencies run: python -m pip install --disable-pip-version-check "jsonschema>=4.23,<5" + - name: Install locked TypeScript parser for semantic production checks + run: npm ci --ignore-scripts + - name: Preview full-public shard run: | python3 examples/run-smokes.py \ diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md new file mode 100644 index 0000000000..37c4f5f961 --- /dev/null +++ b/docs/reference/glossary.md @@ -0,0 +1,246 @@ +# Semantic Vocabulary Glossary / 语义词表 + + + +This is a generated view of the curated registry, not another authority. +这是注册表的生成视图;修改定义应编辑 owner 和注册表,然后重新生成。 + +Equal spellings or value sets do not prove equal meaning. Producers write +values; consumers interpret or pass them through; only owners define sets. +同名或相同值集不等于同一语义。生产者写值,消费者解释或透传,owner 定义集合。 + +See the [RFC](../architecture/rfcs/semantic-vocabulary-convergence-v0.md) +and its [中文版本](../architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md) +for scope, compatibility, proof boundaries and migration gates. + +## agent_scope_frontier_action + +Frontier verdict for an agent-scoped lane. New v1 payloads use agent_scope_frontier.action; the root should-run effective_action projects the same value through its registered disjoint union. Legacy v0 signed payloads remain readable without rewriting. + +- Tier / 层级: `kernel`; status / 状态: `canonical`. +- python: [`AgentScopeFrontierAction`](../../loopx/control_plane/agents/agent_scope_frontier.py). +- typescript: [`AGENT_SCOPE_FRONTIER_ACTIONS`](../../loopx/control_plane/agents/agent_scope_frontier.generated.ts). +- Values / 值: `agent_scope_exhausted`, `agent_scope_wait`, `reassignment_required`, `successor_replan_required`. + +## delivery_continuity_preemption + +Reason a delivery continuity is preempted. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`DELIVERY_CONTINUITY_PREEMPTIONS`](../../loopx/control_plane/turn_driver/delivery_continuity.py). +- typescript: [`DELIVERY_CONTINUITY_PREEMPTIONS`](../../loopx/control_plane/turn_driver/delivery_continuity.ts). +- Values / 值: `heartbeat_receipt`, `blocking_work_lane`, `autonomous_replan`, `control_repair`, `delivery_not_allowed`. + +## delivery_outcome + +Outcome class of a delivered work item. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`DeliveryOutcome`](../../loopx/control_plane/work_items/delivery_outcome.py). +- typescript: [`DELIVERY_OUTCOMES`](../../loopx/control_plane/work_items/delivery_outcome.ts). +- Values / 值: `surface_only`, `outcome_gap`, `outcome_progress`, `primary_goal_outcome`. + +## delivery_workspace_identity_kind + +How a delivery workspace is identified. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`DELIVERY_WORKSPACE_IDENTITY_KINDS`](../../loopx/control_plane/agents/delivery_workspace.py). +- typescript: [`DELIVERY_WORKSPACE_IDENTITY_KINDS`](../../loopx/control_plane/agents/delivery_workspace.ts). +- Values / 值: `git_repository`, `local_goal`. + +## delivery_workspace_kind + +Workspace kind a delivery runs in. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`DELIVERY_WORKSPACE_KINDS`](../../loopx/control_plane/agents/delivery_workspace.py). +- typescript: [`DELIVERY_WORKSPACE_KINDS`](../../loopx/control_plane/agents/delivery_workspace.ts). +- Values / 值: `canonical_checkout`, `independent_git_worktree`, `local_goal_workspace`. + +## delivery_workspace_requirement + +Workspace requirement a settlement asserts before delivery. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`DELIVERY_WORKSPACE_REQUIREMENTS`](../../loopx/control_plane/quota/settlement_workspace_causality.py). +- typescript: [`DELIVERY_WORKSPACE_REQUIREMENTS`](../../loopx/control_plane/quota/settlement_workspace_causality.ts). +- Values / 值: `required`, `not_required`, `unknown`. + +## effective_action + +Compacted should-run verdict carried by status/should-run payloads and the Turn Envelope; the Python enum owns the finite value domain while the wire field remains a string for compatibility. + +- Tier / 层级: `kernel`; status / 状态: `merge_candidate`. +- python: [`EffectiveAction`](../../loopx/control_plane/quota/effective_action.py). +- typescript: [`EFFECTIVE_ACTIONS`](../../loopx/control_plane/quota/effective_action.generated.ts). +- Values / 值: `agent_monitor_only`, `agent_workspace_repair`, `automation_prompt_upgrade_required`, `autonomous_replan_required`, `blocked_health`, `blocked_wait`, `boundary_projection_repair`, `capability_bridge_repair`, `control_plane_health_repair`, `control_plane_projection_repair`, `control_plane_repair`, `coordinate_task_bundle`, `external_evidence_observe`, `governed_capability_intent`, `heartbeat_receipt_write_failed`, `heartbeat_settled_skip`, `lark_inbox_reply_due`, `monitor_due`, `monitor_quiet_skip`, `normal_run`, `operator_gate_notify`, `operator_inbox_material_review_due`, `outcome_floor_recovery`, `peer_coordination_blocked`, `quota_skip`, `runtime_user_gate_projection_repair`, `scoped_user_gate_fallback`, `state_projection_gap_repair`, `terminal_no_followup`, `throttled_skip`, `todo_decision_scope_projection_repair`, `unsettled_host_turn_recovery`. + +## goal_amendment_class + +Class of a proposed Goal amendment. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`GOAL_AMENDMENT_CLASSES`](../../loopx/control_plane/goals/goal_amendment_proposal.py). +- typescript: [`GOAL_AMENDMENT_CLASSES`](../../loopx/control_plane/goals/goal_amendment_proposal.ts). +- Values / 值: `lane_route`, `shared_work_graph`, `shared_acceptance`, `protected_authority`. + +## goal_amendment_proposal_admission + +Admission verdict for a Goal amendment proposal. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`GOAL_AMENDMENT_PROPOSAL_ADMISSIONS`](../../loopx/control_plane/goals/goal_amendment_proposal.py). +- typescript: [`GOAL_AMENDMENT_PROPOSAL_ADMISSIONS`](../../loopx/control_plane/goals/goal_amendment_proposal.ts). +- Values / 值: `admitted`, `needs_rebase`. + +## goal_amendment_proposal_admission_fact + +Fact recorded with a Goal amendment admission verdict. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`GOAL_AMENDMENT_PROPOSAL_ADMISSION_FACTS`](../../loopx/control_plane/goals/goal_amendment_proposal.py). +- typescript: [`GOAL_AMENDMENT_PROPOSAL_ADMISSION_FACTS`](../../loopx/control_plane/goals/goal_amendment_proposal.ts). +- Values / 值: `base_state_event_basis_sequence_behind_derived_head`, `base_source_basis_digest_mismatch`, `base_source_basis_unverifiable`, `base_revision_basis_superseded`. + +## handoff_mode + +Authority handoff mode between agents. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`HandoffMode`](../../loopx/control_plane/coordination/authority_core.py). +- typescript: [`HANDOFF_MODES`](../../loopx/control_plane/coordination/handoff_mode_policy.ts). +- Values / 值: `legacy`, `soft_claim`, `hard_lease`. + +## lease_action + +Authority-core lease mutation verb. + +- Tier / 层级: `kernel`; status / 状态: `legacy`. +- python: [`LeaseAction`](../../loopx/control_plane/coordination/authority_core.py). +- Values / 值: `acquire`, `renew`, `transfer`, `release`. +- Compatibility only / 兼容保留: `acquire`, `release`, `renew`, `transfer`. + +## loop_disposition + +Pure controller verdict for the outer loop after combining the last Turn receipt with the fresh route. + +- Tier / 层级: `kernel`; status / 状态: `canonical`. +- python: [`LoopDisposition`](../../loopx/control_plane/turn_driver/loop_controller.py). +- Values / 值: `run_now`, `capability_action_required`, `wait`, `stop`, `user_action_required`, `repair`, `replan`, `terminal`. + +## receipt_bound_monitor_phase + +Receipt-bound phase of a monitor poll settlement. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`ReceiptBoundMonitorPhase`](../../loopx/control_plane/effect_program.py). +- typescript: [`RECEIPT_BOUND_MONITOR_PHASES`](../../loopx/control_plane/quota/settlement_phase.ts). +- Values / 值: `poll_due`, `settlement_pending`, `settled`. + +## receipt_bound_replay_phase + +Receipt-bound phase of a Turn journal replay settlement. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`ReceiptBoundReplayPhase`](../../loopx/control_plane/effect_program.py). +- typescript: [`RECEIPT_BOUND_REPLAY_PHASES`](../../loopx/control_plane/quota/settlement_phase.ts). +- Values / 值: `open`, `settlement_pending`, `settled`. + +## scheduler_cadence_transition + +Legal scheduler cadence state transition. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`SchedulerCadenceTransition`](../../loopx/control_plane/scheduler/state_transition_rules.py). +- typescript: [`SCHEDULER_CADENCE_TRANSITIONS`](../../loopx/control_plane/scheduler/state_transition_rules.ts). +- Values / 值: `initial`, `identity_reset`, `retry_unacknowledged_failure`, `hold_active_initial`, `advance_after_interval`, `hold_until_interval`. + +## scheduler_host_transition + +Legal scheduler host state transition. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`SchedulerHostTransition`](../../loopx/control_plane/scheduler/state_transition_rules.py). +- typescript: [`SCHEDULER_HOST_TRANSITIONS`](../../loopx/control_plane/scheduler/state_transition_rules.ts). +- Values / 值: `apply_required`, `host_match_ack_required`, `recorded_failure_suppressed`, `settled`. + +## settlement_binding_kind + +How a settlement step binds to its receipt. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`SettlementBindingKind`](../../loopx/control_plane/effect_program.py). +- typescript: [`SETTLEMENT_BINDING_KINDS`](../../loopx/control_plane/effect_program.ts). +- Values / 值: `todo`, `autonomous_replan`, `unbound`. + +## settlement_failure_kind + +Typed failure class of a settlement step. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`SettlementFailureKind`](../../loopx/control_plane/effect_program.py). +- typescript: [`SETTLEMENT_FAILURE_KINDS`](../../loopx/control_plane/effect_program.ts). +- Values / 值: `invalid_identity`, `receipt_missing`, `identity_mismatch`, `writeback_missing`, `writeback_rejected`, `quota_spend_rejected`, `terminal_closeout_rejected`, `cancelled`, `permission_denied`, `budget_rejected`, `effect_outcome_unknown`. + +## settlement_step_kind + +Effect-program settlement step executed for one Turn. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`SettlementStepKind`](../../loopx/control_plane/effect_program.py). +- typescript: [`SETTLEMENT_STEP_KINDS`](../../loopx/control_plane/effect_program.ts). +- Values / 值: `validation`, `durable_writeback`, `quota_spend`, `terminal_closeout`. + +## todo_completion_continuation + +Continuation declared by a completing Todo. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`TodoCompletionContinuation`](../../loopx/control_plane/todos/completion_state.py). +- typescript: [`TODO_COMPLETION_CONTINUATIONS`](../../loopx/control_plane/todos/completion_state.ts). +- Values / 值: `active_goal`, `successor`, `no_followup`. + +## todo_completion_recovery + +Recovery path when a Todo completion cannot continue. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`TodoCompletionRecovery`](../../loopx/control_plane/todos/completion_state.py). +- typescript: [`TODO_COMPLETION_RECOVERIES`](../../loopx/control_plane/todos/completion_state.ts). +- Values / 值: `same_turn_terminal_closeout`, `lifecycle_reentry_terminal_closeout`. + +## todo_decision_scope_granularity + +Granularity of a Todo decision scope. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`TODO_DECISION_SCOPE_GRANULARITY_VALUES`](../../loopx/control_plane/todos/contract.py). +- typescript: [`TODO_DECISION_SCOPE_GRANULARITIES`](../../loopx/control_plane/todos/decision_metadata.ts). +- Values / 值: `action`, `lane`, `goal`, `project`, `global`. + +## todo_decision_scope_kind + +Scope kind of a Todo decision. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`TODO_DECISION_SCOPE_KIND_VALUES`](../../loopx/control_plane/todos/contract.py). +- typescript: [`TODO_DECISION_SCOPE_KINDS`](../../loopx/control_plane/todos/decision_metadata.ts). +- Values / 值: `private_read`, `write_scope`, `resource`, `production`, `public_claim`, `direction`, `other`. + +## turn_result_kind + +Typed public outcome of one executed Turn as settled by the Turn Journal. + +- Tier / 层级: `kernel`; status / 状态: `canonical`. +- python: [`LoopXTurnResultKind`](../../loopx/control_plane/turn_driver/transaction.py). +- typescript: [`TURN_RESULT_KINDS`](../../loopx/control_plane/turn_driver/settlement.ts). +- Values / 值: `validated_progress`, `validated_completion`, `repair_required`, `replan_required`, `user_action_required`, `wait`, `iteration_failed`, `host_failure`, `validation_failed`, `writeback_failed`, `quota_spend_failed`, `terminal_closeout_failed`. + +## turn_route + +Typed delivery route derived from a fresh should-run decision before a Host is engaged. + +- Tier / 层级: `kernel`; status / 状态: `canonical`. +- python: [`LoopXTurnRoute`](../../loopx/control_plane/turn_driver/driver.py). +- Values / 值: `ready_for_host`, `capability_action_required`, `repair_required`, `replan_required`, `user_action_required`, `wait`, `blocked`, `contract_error`. diff --git a/examples/control_plane/agent-scope-projection-characterization-smoke.py b/examples/control_plane/agent-scope-projection-characterization-smoke.py index b44cffafa8..0bec4bfa15 100644 --- a/examples/control_plane/agent-scope-projection-characterization-smoke.py +++ b/examples/control_plane/agent-scope-projection-characterization-smoke.py @@ -258,9 +258,9 @@ def assert_agent_scope_frontier_builder_contract() -> None: extra_fields={"cleared_without_successor_handoff_gates": [{"todo_id": "todo_gate"}]}, ) - assert payload["schema_version"] == "agent_scope_frontier_v0" + assert payload["schema_version"] == "agent_scope_frontier_v1" assert payload["action"] == "successor_replan_required" - assert payload["effective_action"] == payload["action"] + assert "effective_action" not in payload assert payload["blocks_delivery"] is True assert payload["requires_replan"] is True assert payload["quiet_noop_allowed"] is False @@ -302,7 +302,7 @@ def assert_agent_scope_frontier_and_hint_contract() -> None: ) assert frontier is not None, frontier - assert frontier["schema_version"] == "agent_scope_frontier_v0" + assert frontier["schema_version"] == "agent_scope_frontier_v1" assert frontier["action"] == "successor_replan_required" assert frontier["requires_replan"] is True assert frontier["quiet_noop_allowed"] is False diff --git a/examples/control_plane/work-lane-contract-smoke.py b/examples/control_plane/work-lane-contract-smoke.py index eb544e821b..f9e67324c1 100644 --- a/examples/control_plane/work-lane-contract-smoke.py +++ b/examples/control_plane/work-lane-contract-smoke.py @@ -11,24 +11,24 @@ sys.path.insert(0, str(SMOKE_DIR)) sys.path.insert(0, str(REPO_ROOT)) -from loopx.control_plane.scheduler.execution_context import ( +from loopx.control_plane.scheduler.execution_context import ( # noqa: E402 - standalone smoke bootstraps repo imports SchedulerRuntimeProfile, scheduler_execution_context_for_runtime_profile, ) -from loopx.control_plane.todos.contract import ( +from loopx.control_plane.todos.contract import ( # noqa: E402 - standalone smoke bootstraps repo imports TODO_TASK_CLASS_ADVANCEMENT, TODO_TASK_CLASS_MONITOR, ) -from loopx.quota import ( +from loopx.quota import ( # noqa: E402 - standalone smoke bootstraps repo imports build_quota_should_run as _build_quota_should_run, render_quota_should_run_markdown, ) -from loopx.status import ( +from loopx.status import ( # noqa: E402 - standalone smoke bootstraps repo imports compact_todo_group, compact_post_handoff_run, normalize_todo_task_class, ) -from work_lane_contract_fixtures import ( +from work_lane_contract_fixtures import ( # noqa: E402 - standalone smoke bootstraps repo imports FUTURE_DUE_AT, GOAL_ID, PAST_DUE_AT, @@ -1393,8 +1393,9 @@ def assert_peer_requires_reassignment_when_only_other_peer_has_claimed_work() -> assert guard["effective_action"] == "reassignment_required", guard assert "agent_lane_next_action" not in guard, guard frontier = guard["agent_scope_frontier"] - assert frontier["schema_version"] == "agent_scope_frontier_v0", frontier + assert frontier["schema_version"] == "agent_scope_frontier_v1", frontier assert frontier["action"] == "reassignment_required", frontier + assert "effective_action" not in frontier, frontier assert frontier["agent_id"] == "codex-side-bypass", frontier assert "primary_agent" not in frontier, frontier assert frontier["candidate_counts"]["current_agent_claimed_advancement_count"] == 0, frontier diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index d01dc46069..8c204d7842 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -34,19 +34,36 @@ typescript_facts, ) +from loopx.semantics.production import ( # noqa: E402 + collect_production, validate_production, probe_turn_result_input_domain, quota_action_domain, collect_literal_uses, +) +from loopx.semantics.python_production import scan_python_production # noqa: E402 +from scripts.generate_semantic_bindings import build_artifacts # noqa: E402 + REGISTRY_PATH = REPO_ROOT / "loopx" / "semantics" / "vocabulary_v0.json" REGISTRY_SCHEMA_VERSION = "loopx_semantic_vocabulary_v0" VALUE_SHAPE = re.compile(r"^[a-z][a-z0-9_]*$") +SYMBOL_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") OWNER_SHAPE = re.compile(r"^[A-Za-z0-9_./-]+\.(py|ts)::[A-Za-z_][A-Za-z0-9_]*$") -QUOTED = re.compile(r'''["']([^"']*)["']''') REGISTRY_KEYS = { "schema_version", "rfc", "inventory", "policy", "coverage_floor", "vocabularies", "relations", "projections", "schema_versions", "retirement_ledger", "dual_runtime_twins", "inventory_ratchets", - "formal_model", + "formal_model", "scope_declarations", } VOCABULARY_KEYS = {"meaning", "tier", "status", "owners", "values"} -VOCABULARY_OPTIONAL_KEYS = {"literal_scan", "variable_sourced_values", "value_notes", "deprecated_values"} +VOCABULARY_OPTIONAL_KEYS = { + "literal_scan", + "variable_sourced_values", + "value_notes", + "deprecated_values", + "producers", + "compatibility_only", + "return_producers", + "return_paths", + "call_producers", + "input_producer", +} TIERS = {"kernel", "cross_runtime", "cross_module"} STATUSES = {"canonical", "legacy", "merge_candidate"} FORMAL_MODEL_KEYS = { @@ -78,7 +95,7 @@ # silently; that is the gap the anchor exists to close. COVERAGE_ANCHOR = { "vocabularies": 26, - "owner_symbols": 46, + "owner_symbols": 49, "literal_scan_fields": 1, "projections": 1, "relations": 9, @@ -86,6 +103,30 @@ } COVERAGE_SUFFIX_ANCHOR = (".py", ".ts") LITERAL_SCAN_ROOTS = ["loopx"] +PRODUCER_VOCABULARY_ANCHOR = { + "effective_action", "turn_route", "loop_disposition", "agent_scope_frontier_action", "turn_result_kind", "lease_action", +} +RETURN_PRODUCER_ANCHOR = { + "turn_route": {"loopx/control_plane/turn_driver/driver.py::_typed_route", "loopx/control_plane/turn_driver/loop_controller.py::_envelope_route", "loopx/control_plane/turn_driver/driver.py::build_loopx_turn_plan"}, + "loop_disposition": {"loopx/control_plane/turn_driver/loop_controller.py::_route_to_disposition"}, + "effective_action": {"loopx/control_plane/quota/decision_summary.py::quota_effective_action", "loopx/control_plane/quota/decision_summary.py::_task_orchestration_effective_action"}, + "turn_result_kind": {"loopx/control_plane/turn_driver/executor.py::_task_validation_receipt"}, +} +# Reviewed output arguments/paths replace the old any-enum-use heuristic. These +# anchors retain that coverage if registry metadata is accidentally removed. +CALL_PRODUCER_ANCHOR = { + "loop_disposition": {"loopx/control_plane/turn_driver/loop_controller.py::_disposition": ["disposition"]}, + "agent_scope_frontier_action": {"loopx/control_plane/agents/agent_scope_frontier.py::build_agent_scope_frontier_payload": ["action"]}, + "turn_result_kind": { + "loopx/control_plane/turn_driver/executor.py::_host_failure": ["kind"], + "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt": ["recovery_kind"], + }, +} +RETURN_PATH_ANCHOR = { + "effective_action": {"loopx/control_plane/quota/decision_summary.py::_task_orchestration_effective_action": [0]}, + "turn_route": {"loopx/control_plane/turn_driver/driver.py::build_loopx_turn_plan": ["route", "kind"]}, + "turn_result_kind": {"loopx/control_plane/turn_driver/executor.py::_task_validation_receipt": ["recovery_kind"]}, +} TWIN_ROOT_ANCHOR = "loopx/control_plane" TWIN_BUDGET_ANCHOR = 43 BUDGET_ANCHOR = { @@ -96,6 +137,7 @@ "schema_version_same_runtime_forks": 7, "multi_value_twins": 19, "multi_value_forks": 4, + "multi_value_forks_semantic": 3, "multi_value_fork_definitions": 10, "same_runtime_forks_semantic": 18, "conflicting_values_semantic": 2, @@ -103,12 +145,12 @@ # Budgets for the legacy should-run decision fields, anchored the same way so a # single diff cannot widen a retirement budget to keep a field alive. RETIREMENT_ANCHOR = { - "execution_obligation": (21, 1), - "heartbeat_recommendation": (18, 1), - "work_lane_contract": (32, 3), - "external_evidence_observation": (11, 1), - "goal_boundary": (35, 2), - "protocol_action_packet": (7, 2), + "execution_obligation": (20, 1), + "heartbeat_recommendation": (17, 1), + "work_lane_contract": (29, 3), + "external_evidence_observation": (8, 1), + "goal_boundary": (30, 2), + "protocol_action_packet": (5, 2), } RATCHET_KEYS = ( "same_runtime_forks", @@ -118,26 +160,12 @@ "schema_version_same_runtime_forks", "multi_value_twins", "multi_value_forks", + "multi_value_forks_semantic", "multi_value_fork_definitions", "same_runtime_forks_semantic", "conflicting_values_semantic", ) -# Dispatch forms the literal scan recognises. Fixed here, not in the registry, so -# the registry cannot narrow what the scan sees. ``{f}`` is the field name. -DISPATCH_FORMS = ( - # Python/TypeScript comparisons, including wrapped field reads. - r"""{f}\b[^\n]*?(?:===|!==|==|!=)\s*["']([^"']*)["']""", - # Assignment or object/dict key. - r"""{f}["\'\]\)]*\s*(?::|=|\bis)\s*["']([^"']*)["']""", - # TypeScript conditional expression. - r"""{f}\b[^"\n]*?\?\s*["']([^"']*)["']\s*:\s*["']([^"']*)["']""", - # Membership in an inline collection. - r"""{f}["\'\]\)]*[^"\n]*?\bin\s*[\(\[\{{]([^\)\]\}}]*)[\)\]\}}]""", - # Python conditional expression. - r"""{f}\b[^"\n]*?=\s*["']([^"']*)["']\s+if\b[^"\n]*?\belse\s+["']([^"']*)["']""", -) -MEMBERSHIP_FORM_INDEX = 3 class Drift(AssertionError): @@ -183,6 +211,33 @@ def load_registry() -> dict[str, Any]: extra = set(vocabulary.get(key, {})) - set(values) require(not extra, f"{name}: {key} names unregistered values {sorted(extra)}") require(set(vocabulary.get("deprecated_values", [])) <= set(values), f"{name}: deprecated_values must be a subset of values") + producers = vocabulary.get("producers") + if producers is not None: + require(isinstance(producers, list), f"{name}: producers must be a list") + require(bool(producers) or set(vocabulary.get('compatibility_only', {})) == set(values), f"{name}: empty producers require every value to be compatibility-only") + require(all(isinstance(site, str) and OWNER_SHAPE.match(site) for site in producers), f"{name}: producers must be module::Symbol sites") + if 'input_producer' in vocabulary: + require(name == 'turn_result_kind', f"{name}: no executable input producer verifier is implemented") + require(vocabulary['input_producer'] == 'loopx/control_plane/turn_driver/transaction.py::_result_kind', f"{name}: unrecognised input producer") + returns = vocabulary.get("return_producers", []) + require(isinstance(returns, list) and all(isinstance(site, str) and OWNER_SHAPE.match(site) for site in returns), f"{name}: return_producers must be module::Symbol sites") + require(set(returns) <= set(producers or []), f"{name}: return_producers must also be registered producers") + paths = vocabulary.get('return_paths', {}) + require(isinstance(paths, dict) and set(paths) <= set(returns), f"{name}: return_paths must name declared return producers") + require(all(isinstance(path, list) and path and all(type(key) in (str, int) for key in path) + for path in paths.values()), f"{name}: return_paths must select literal fields or tuple indexes") + calls = vocabulary.get('call_producers', {}) + require(isinstance(calls, dict), f"{name}: call_producers must be a builder-to-parameters object") + require(all(isinstance(site, str) and OWNER_SHAPE.match(site) and isinstance(names, list) and names + and all(isinstance(arg, str) and arg.isidentifier() for arg in names) + and len(names) == len(set(names)) for site, names in calls.items()), + f"{name}: call_producers must name qualified builders and distinct parameters") + compatibility = vocabulary.get("compatibility_only") + if compatibility is not None: + require(isinstance(compatibility, dict), f"{name}: compatibility_only must be an object") + for value, metadata in compatibility.items(): + require(isinstance(metadata, dict) and set(metadata) == {"reason", "retirement"}, f"{name}: compatibility_only.{value} needs reason and retirement") + require(all(isinstance(item, str) and item.strip() for item in metadata.values()), f"{name}: compatibility_only.{value} metadata must be non-empty text") scan = vocabulary.get("literal_scan") if scan is not None: require(set(scan) == {"field", "roots", "suffixes"}, f"{name}: literal_scan keys must be field, roots, suffixes") @@ -239,6 +294,23 @@ def check_formal_model(model: dict[str, Any]) -> None: def check_coverage_floor(registry: dict[str, Any]) -> str: + try: + quota_action_domain(registry) + except ValueError as error: + raise Drift(str(error)) from error + require( + registry['vocabularies']['turn_result_kind'].get('input_producer') == 'loopx/control_plane/turn_driver/transaction.py::_result_kind', + 'turn_result_kind: input producer coverage must retain the anchored decoder', + ) + for name in PRODUCER_VOCABULARY_ANCHOR: + require("producers" in registry["vocabularies"][name], f"{name}: producer coverage dropped below PRODUCER_VOCABULARY_ANCHOR") + for name, required in RETURN_PRODUCER_ANCHOR.items(): + actual_returns = set(registry['vocabularies'][name].get('return_producers', [])) + require(required <= actual_returns, f"{name}: return producer coverage dropped below RETURN_PRODUCER_ANCHOR") + for metadata, anchor in (("call_producers", CALL_PRODUCER_ANCHOR), ("return_paths", RETURN_PATH_ANCHOR)): + for name, vocabulary in registry['vocabularies'].items(): + require(vocabulary.get(metadata, {}) == anchor.get(name, {}), + f"{name}: {metadata} must retain anchored output evidence exactly (default empty)") for vocabulary in registry["vocabularies"].values(): if scan := vocabulary.get("literal_scan"): require(scan["roots"] == LITERAL_SCAN_ROOTS, "literal_scan roots must cover loopx") @@ -319,20 +391,9 @@ def check_owned_vocabularies(registry: dict[str, Any], inventory: dict[str, Any] def scan_literals(field: str, roots: list[str], suffixes: list[str], sources: list[SourceFile]) -> dict[str, set[str]]: - observed: dict[str, set[str]] = {} - forms = [re.compile(form.format(f=re.escape(field))) for form in DISPATCH_FORMS] - for root in roots: - for file in sources: - if file.suffix not in suffixes or not file.path.startswith(root.rstrip("/") + "/"): - continue - for index, form in enumerate(forms): - for match in form.finditer(file.text): - tokens = QUOTED.findall(match.group(1)) if index == MEMBERSHIP_FORM_INDEX else list(match.groups()) - for token in tokens: - if token == "": - continue # ``?? ""`` and ``or ""`` clear the field; not a value - observed.setdefault(token, set()).add(file.path) - return observed + selected = [source for source in sources if source.suffix in suffixes + and any(source.path.startswith(root.rstrip('/') + '/') for root in roots)] + return collect_literal_uses(REPO_ROOT, field, selected) def check_literal_vocabularies(registry: dict[str, Any], sources: list[SourceFile]) -> None: @@ -341,17 +402,58 @@ def check_literal_vocabularies(registry: dict[str, Any], sources: list[SourceFil if not scan: continue observed = scan_literals(scan["field"], scan["roots"], scan["suffixes"], sources) - expected = set(vocabulary["values"]) + expected = quota_action_domain(registry) if name == 'effective_action' else set(vocabulary["values"]) unregistered = {value: sorted(files) for value, files in observed.items() if value not in expected} require(not unregistered, f"{name}: literals not in the registry (register them or use a registered value): {unregistered}") + if name == 'effective_action': + require(not observed, + f"{name}: bare action literals outside the owner: " + f"{ {value: sorted(paths) for value, paths in observed.items()} }; " + "import EffectiveAction or AgentScopeFrontierAction instead") variable_sourced = vocabulary.get("variable_sourced_values", {}) for value, producer in variable_sourced.items(): text = (REPO_ROOT / producer).read_text(encoding="utf-8", errors="replace") require(f'"{value}"' in text, f"{name}: variable-sourced value {value} is no longer produced by {producer}") - unused = sorted(expected - set(observed) - set(variable_sourced)) + owner_values_seen = { + value + for owner in vocabulary["owners"].values() + if owner + for value in owner_values(owner) + } + unused = sorted( + set(vocabulary['values']) - set(observed) - set(variable_sourced) - owner_values_seen + ) require(not unused, f"{name}: registry lists values no module carries: {unused}") +# --- bounded producer scan ---------------------------------------------------------- + + +def _producer_literals(field: str, source: SourceFile) -> set[str]: + # Compatibility helper for direct-form mutation fixtures. No enum definitions + # are supplied, so these tests cannot accidentally count owners as producers. + if source.suffix != '.py': + return set() + rows = scan_python_production(source, field=field, enums={}) + return set().union(*(row.values for row in rows)) + + +def check_producers(registry: dict[str, Any], sources: list[SourceFile]) -> list[str]: + unknown: list[str] = [] + for name, vocabulary in registry['vocabularies'].items(): + if 'producers' not in vocabulary: + continue # Other kernel families retain an explicit M0.5 coverage gap. + try: + rows = collect_production(REPO_ROOT, vocabulary, sources) + if name == 'turn_result_kind': + rows.extend(probe_turn_result_input_domain(vocabulary)) + field_domain = quota_action_domain(registry) if name == 'effective_action' else None + unknown.extend(validate_production(name, vocabulary, rows, field_domain=field_domain)) + except ValueError as error: + raise Drift(str(error)) from error + return sorted(set(unknown)) + + # --- relations, projections, schema versions ---------------------------------------- @@ -369,7 +471,11 @@ def resolve(member: str) -> None: resolve(member) for shared in registry["relations"]["shared_field_names"]: for slot in shared["slots"]: - if "vocabulary" in slot: + if "vocabularies" in slot: + require(shared['field'] == 'effective_action' and slot['slot'] == 'should_run.effective_action', + 'only the anchored should-run field has a composed action domain') + quota_action_domain(registry) + elif "vocabulary" in slot: require(slot["vocabulary"] in vocabularies, f"shared field slot names unknown vocabulary {slot['vocabulary']}") else: for value in slot["values"]: @@ -412,6 +518,47 @@ def check_schema_version_owners(registry: dict[str, Any], sources: list[SourceFi require(values == {entry["value"]}, f"schema version {name} carries {sorted(values)}; registry says {entry['value']}") +def check_scope_declarations(registry: dict[str, Any], inventory: dict[str, Any]) -> int: + """Validate explicit bounded-context exceptions and return semantic fork count. + + The raw inventory remains unchanged. A declaration can remove a known, + reviewed bounded-context reuse from the semantic budget only when every + defining module is named explicitly. Spelling or directory proximity never + infers a scope. + """ + declarations = registry["scope_declarations"] + forks = {entry["name"]: entry for entry in inventory["duplicate_definitions"]["multi_value_forks"]} + for name, declaration in declarations.items(): + require(SYMBOL_NAME.match(name) is not None, f"scope declaration name must be an identifier: {name}") + require(set(declaration) == {"kind", "contexts"}, f"{name}: scope declaration keys must be kind and contexts") + require(declaration["kind"] == "bounded_context", f"{name}: only bounded_context is supported") + require(name in forks, f"{name}: scope declaration does not resolve to a multi-value fork") + contexts = declaration["contexts"] + require(isinstance(contexts, list) and contexts, f"{name}: contexts must be a non-empty list") + context_ids: set[str] = set() + owner_modules: set[str] = set() + for context in contexts: + require(set(context) == {"id", "owner"}, f"{name}: each context must have id and owner") + context_id = context["id"] + require(isinstance(context_id, str) and VALUE_SHAPE.match(context_id) is not None, + f"{name}: context id must be lower snake_case: {context_id!r}") + require(context_id not in context_ids, f"{name}: duplicate context id {context_id}") + context_ids.add(context_id) + owner = context["owner"] + require(isinstance(owner, str) and OWNER_SHAPE.match(owner) is not None, + f"{name}: context owner must be module::Symbol: {owner!r}") + module, symbol = owner.split("::") + require(symbol == name, f"{name}: context owner symbol must be {name}, got {symbol}") + owner_modules.add(module) + require(len(owner_modules) == len(contexts), f"{name}: each context must have a distinct owner module") + defining_modules = {item["module"] for item in forks[name]["definitions"]} + require(owner_modules == defining_modules, + f"{name}: contexts must name every defining module exactly once; " + f"declared={sorted(owner_modules)} actual={sorted(defining_modules)}") + undeclared = set(forks) - set(declarations) + return len(undeclared) + + # --- ratchets ----------------------------------------------------------------------- @@ -424,7 +571,7 @@ def check_retirement_budgets(registry: dict[str, Any], sources: list[SourceFile] (".py", "python_module_budget", RETIREMENT_ANCHOR[field][0]), (".ts", "typescript_module_budget", RETIREMENT_ANCHOR[field][1]), ): - actual = sum(1 for file in sources if file.suffix == suffix and field in file.text) + actual = count_identifier_modules(field, suffix, sources) require(actual <= budgets[key], f"legacy field {field} grew to {actual} {suffix} modules; budget is {budgets[key]}") require( budgets[key] == anchored, @@ -435,6 +582,23 @@ def check_retirement_budgets(registry: dict[str, Any], sources: list[SourceFile] return report +def count_identifier_modules(field: str, suffix: str, sources: list[SourceFile]) -> int: + """Count modules containing the standalone field token. + + This is intentionally a conservative lexical metric. It removes the known + ``goal_boundary_repair`` false positive without claiming to prove that every + remaining occurrence is a reader or that computed accesses are absent. + """ + pattern = re.compile( + rf"(? str: entry = registry["dual_runtime_twins"] require(entry["root"] == TWIN_ROOT_ANCHOR, "dual_runtime_twins root differs from TWIN_ROOT_ANCHOR") @@ -450,18 +614,20 @@ def check_inventory(registry: dict[str, Any], sources: list[SourceFile]) -> tupl committed = inventory_path.read_text(encoding="utf-8") inventory = build_inventory(REPO_ROOT, sources=sources) require(inventory["schema_version"] == INVENTORY_SCHEMA_VERSION, "inventory schema drift") - require(render_inventory(inventory) == committed, f"{registry['inventory']} is stale; run python3.11 scripts/generate_semantic_inventory.py and commit the result") + require(render_inventory(inventory) == committed, f"{registry['inventory']} is stale; run uv run python scripts/generate_semantic_inventory.py and commit the result") + semantic_multi_value_forks = check_scope_declarations(registry, inventory) ratchets = registry["inventory_ratchets"] summary = inventory["summary"] parts = [] for key in RATCHET_KEYS: - require(summary[key] <= ratchets[key], f"inventory {key} grew to {summary[key]}; budget is {ratchets[key]}") + actual = semantic_multi_value_forks if key == "multi_value_forks_semantic" else summary[key] + require(actual <= ratchets[key], f"inventory {key} grew to {actual}; budget is {ratchets[key]}") require( ratchets[key] == BUDGET_ANCHOR[key], f"inventory {key} budget is {ratchets[key]} but BUDGET_ANCHOR pins {BUDGET_ANCHOR[key]}; " "the registry and the anchor move together in one diff (see BUDGET_ANCHOR in this smoke)", ) - parts.append(f"{key}={summary[key]}/{ratchets[key]}") + parts.append(f"{key}={actual}/{ratchets[key]}") return inventory, " ".join(parts) @@ -471,7 +637,14 @@ def main() -> int: sources = load_sources(REPO_ROOT) inventory, ratchets = check_inventory(registry, sources) check_owned_vocabularies(registry, inventory) + for path, expected in build_artifacts().items(): + require( + path.is_file() and path.read_text(encoding="utf-8") == expected, + f"{path.relative_to(REPO_ROOT)} is stale; " + "run uv run python scripts/generate_semantic_bindings.py and commit the result", + ) check_literal_vocabularies(registry, sources) + unknown_producers = check_producers(registry, sources) check_relations(registry) check_projections(registry) check_schema_version_owners(registry, sources) @@ -482,6 +655,12 @@ def main() -> int: print(" " + ratchets) print(" " + " ".join(budgets)) print(" " + twins) + print(f" unresolved_producer_sites={len(unknown_producers)} (not proven safe)") + uncovered = [name for name, v in registry['vocabularies'].items() if v['tier'] == 'kernel' and 'producers' not in v] + print(f" kernel_producer_coverage_pending={','.join(uncovered)}") + if '--report' in sys.argv[1:]: + for site in unknown_producers: + print(f" unknown_producer: {site}") return 0 diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 1075fa5100..fb4b5a3c62 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..control_plane.quota.effective_action import EffectiveAction import argparse from collections.abc import Callable, Mapping @@ -226,7 +227,7 @@ def _apply_requested_quota_action_selection_preflight( pending_selection_workspace_repair_qualified = ( selection_binding == "pending_action_selection" and payload.get("workspace_repair_allowed") is True - and payload.get("effective_action") == "agent_workspace_repair" + and payload.get("effective_action") == EffectiveAction.AGENT_WORKSPACE_REPAIR.value and execution_obligation.get("kind") == "agent_workspace_repair" and execution_obligation.get("must_attempt_work") is True and agent_channel.get("must_attempt") is True @@ -276,7 +277,7 @@ def _apply_requested_quota_action_selection_preflight( "ok": False, "decision": "skip", "should_run": False, - "effective_action": error_code, + "effective_action": EffectiveAction.QUOTA_SKIP.value, "state": error_code, "waiting_on": "codex", "status": error_code, @@ -562,7 +563,7 @@ def handle_quota_command( turn_instance_id=heartbeat_turn_id, ) if ( - payload.get("effective_action") == "monitor_quiet_skip" + payload.get("effective_action") == EffectiveAction.MONITOR_QUIET_SKIP.value or existing_stall is not None ): poll = record_quota_monitor_poll( diff --git a/loopx/cli_commands/quota_scheduler_followup.py b/loopx/cli_commands/quota_scheduler_followup.py index 84e518b8e8..0e9fbef4ea 100644 --- a/loopx/cli_commands/quota_scheduler_followup.py +++ b/loopx/cli_commands/quota_scheduler_followup.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..control_plane.quota.effective_action import EffectiveAction import argparse from collections.abc import Callable, Mapping @@ -181,7 +182,7 @@ def build_scheduler_followup_payload( turn_instance_id and receipt_todo_id is None and receipt_replan_id is not None - and before_decision.get("effective_action") == "heartbeat_settled_skip" + and before_decision.get("effective_action") == EffectiveAction.HEARTBEAT_SETTLED_SKIP.value ): return { "ok": True, diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index bf36f1e3c2..ce6fd1605c 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..control_plane.quota.effective_action import EffectiveAction import argparse import json @@ -202,7 +203,7 @@ def build_turn_decision( args.turn_command == "run-once" and args.host == "codex-cli" and not resume_requested - and turn_envelope.get("effective_action") != "governed_capability_intent" + and turn_envelope.get("effective_action") != EffectiveAction.GOVERNED_CAPABILITY_INTENT.value ): session_binding = codex_cli_session_binding(runtime_root, turn_envelope) payload = build_loopx_turn_plan( diff --git a/loopx/control_plane/agents/agent_scope_frontier.generated.ts b/loopx/control_plane/agents/agent_scope_frontier.generated.ts new file mode 100644 index 0000000000..80a14b8ec7 --- /dev/null +++ b/loopx/control_plane/agents/agent_scope_frontier.generated.ts @@ -0,0 +1,17 @@ +// Generated by scripts/generate_semantic_bindings.py; do not edit. +// Value owner: loopx/control_plane/agents/agent_scope_frontier.py::AgentScopeFrontierAction + +export const AGENT_SCOPE_FRONTIER_ACTIONS = [ + "agent_scope_exhausted", + "agent_scope_wait", + "reassignment_required", + "successor_replan_required", +] as const; +export type AgentScopeFrontierActionValue = (typeof AGENT_SCOPE_FRONTIER_ACTIONS)[number]; + +export const AgentScopeFrontierAction = { + AGENT_SCOPE_EXHAUSTED: AGENT_SCOPE_FRONTIER_ACTIONS[0], + AGENT_SCOPE_WAIT: AGENT_SCOPE_FRONTIER_ACTIONS[1], + REASSIGNMENT_REQUIRED: AGENT_SCOPE_FRONTIER_ACTIONS[2], + SUCCESSOR_REPLAN_REQUIRED: AGENT_SCOPE_FRONTIER_ACTIONS[3], +} as const; diff --git a/loopx/control_plane/agents/agent_scope_frontier.py b/loopx/control_plane/agents/agent_scope_frontier.py index ec0e97bc9a..0dec435cfd 100644 --- a/loopx/control_plane/agents/agent_scope_frontier.py +++ b/loopx/control_plane/agents/agent_scope_frontier.py @@ -1,10 +1,11 @@ from __future__ import annotations from enum import Enum +from collections.abc import Mapping from typing import Any -AGENT_SCOPE_FRONTIER_SCHEMA_VERSION = "agent_scope_frontier_v0" +AGENT_SCOPE_FRONTIER_SCHEMA_VERSION = "agent_scope_frontier_v1" AGENT_LANE_FRONTIER_HINT_SCHEMA_VERSION = "agent_lane_frontier_hint_v0" @@ -29,6 +30,11 @@ def agent_scope_frontier_action(value: Any) -> AgentScopeFrontierAction | None: return None +def read_frontier_action(payload: Mapping[str, Any]) -> str: + """Read the canonical action, retaining the persisted v0 alias as fallback.""" + return str(payload.get("action") or payload.get("effective_action") or "") + + def build_agent_scope_frontier_payload( *, agent_id: str, @@ -41,11 +47,18 @@ def build_agent_scope_frontier_payload( requires_replan: bool = False, extra_fields: dict[str, Any] | None = None, ) -> dict[str, Any]: + reserved = {"schema_version", "action", "effective_action"}.intersection( + extra_fields or {} + ) + if reserved: + raise ValueError( + "agent-scope frontier extra_fields contain reserved keys: " + + ", ".join(sorted(reserved)) + ) payload: dict[str, Any] = { "schema_version": AGENT_SCOPE_FRONTIER_SCHEMA_VERSION, "agent_id": agent_id, "action": action.value, - "effective_action": action.value, "blocks_delivery": True, "quiet_noop_allowed": quiet_noop_allowed, "spend_policy": spend_policy, diff --git a/loopx/control_plane/effect_program.py b/loopx/control_plane/effect_program.py index c9d862f460..8c76270ca9 100644 --- a/loopx/control_plane/effect_program.py +++ b/loopx/control_plane/effect_program.py @@ -117,7 +117,7 @@ class EffectInterpretation: class EffectObservation: decision: str should_run: bool - effective_action: str + effective_action: str | None recommended_action: str action_portfolio: Mapping[str, Any] | None = None planning_horizon: Mapping[str, Any] | None = None @@ -686,7 +686,11 @@ def _effect_turn_from_payload(payload: Any) -> EffectTurn: observation=EffectObservation( decision=str(observation.get("decision") or ""), should_run=observation.get("should_run") is True, - effective_action=str(observation.get("effective_action") or ""), + effective_action=( + str(observation["effective_action"]) + if observation.get("effective_action") is not None + else None + ), recommended_action=str(observation.get("recommended_action") or ""), action_portfolio=( dict(observation["action_portfolio"]) @@ -797,7 +801,7 @@ def interpret_turn_result_packet( agent_id: str | None = None, capabilities: Sequence[str] = (), ) -> EffectTurn: - """Map an existing `loopx_turn_result_v0` packet onto canonical slots.""" + """Project a Turn verdict; the TS owner emits no quota action (None).""" return _effect_turn_from_payload( effect_runtime_result( "effect.interpret_turn_result", diff --git a/loopx/control_plane/effect_program.ts b/loopx/control_plane/effect_program.ts index 48089368ee..d27d934d16 100644 --- a/loopx/control_plane/effect_program.ts +++ b/loopx/control_plane/effect_program.ts @@ -44,10 +44,13 @@ export interface EffectInterpretation { cadence_class: string | null; } -export interface EffectObservation { +export interface EffectObservation< + Decision extends string, + Action extends string | null = string, +> { decision: Decision; should_run: boolean; - effective_action: string; + effective_action: Action; recommended_action: string; action_portfolio: JsonObject | null; planning_horizon: JsonObject | null; @@ -63,10 +66,14 @@ export interface EffectNext { failure_cli_args: readonly string[]; } -export interface EffectTurn { +export interface EffectTurn< + Context, + Decision extends string, + Action extends string | null = string, +> { request: EffectRequest; interpretation: EffectInterpretation; - observation: EffectObservation; + observation: EffectObservation; next_effect: EffectNext; } @@ -343,7 +350,7 @@ export function interpretTurnResultPacket( agent_id?: string | null; capabilities?: readonly string[]; } = {}, -): EffectTurn { +): EffectTurn { const packet = asObject(packetValue); const scheduler = asObject(packet.scheduler_hint); const codexApp = asObject(scheduler.codex_app); @@ -376,7 +383,9 @@ export function interpretTurnResultPacket( observation: { decision: resultKind, should_run: false, - effective_action: truthyString(packet.effective_action) || resultKind, + // A host result carries a verdict, not a new quota decision. Ignore any + // host-supplied action and keep the no-action wire representation explicit. + effective_action: null, recommended_action: truthyString(packet.recommended_action) || "settle the turn receipt", action_portfolio: null, diff --git a/loopx/control_plane/quota/decision_summary.py b/loopx/control_plane/quota/decision_summary.py index 4735d17654..fb1635348b 100644 --- a/loopx/control_plane/quota/decision_summary.py +++ b/loopx/control_plane/quota/decision_summary.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from dataclasses import dataclass from typing import Any, TypedDict @@ -6,7 +7,6 @@ from ...state_projection import actions_are_projection_aligned from ..goals.contract_health import project_contract_health_for_goal from ..goals.goal_frontier import ( - AUTONOMOUS_REPLAN_REQUIRED_MODE, autonomous_replan_decision_allowed, goal_frontier_is_terminal_no_followup, ) @@ -293,7 +293,7 @@ def resolve_quota_run_decision( normal_delivery_allowed = False recovery_delivery_allowed = False should_run = True - effective_action = AUTONOMOUS_REPLAN_REQUIRED_MODE + effective_action = EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED.value reason = ( "autonomous replan obligation is selected before monitor quiet " "or agent-scope wait classification" @@ -318,7 +318,7 @@ def resolve_quota_run_decision( capability_repair_allowed = False workspace_repair_allowed = False should_run = False - effective_action = "terminal_no_followup" + effective_action = EffectiveAction.TERMINAL_NO_FOLLOWUP.value reason = ( "validated closure evidence derives terminal no-follow-up from " "complete todo sources and an empty frontier; stop recurring " @@ -327,7 +327,7 @@ def resolve_quota_run_decision( if automation_prompt_upgrade_required and not terminal_no_followup: should_run = False - effective_action = "automation_prompt_upgrade_required" + effective_action = EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value elif inbox_reply_due: should_run = True normal_delivery_allowed = True @@ -335,7 +335,7 @@ def resolve_quota_run_decision( self_repair_allowed = False capability_repair_allowed = False workspace_repair_allowed = False - effective_action = "lark_inbox_reply_due" + effective_action = EffectiveAction.LARK_INBOX_REPLY_DUE.value reason = ( "a direct Lark question, bot mention, or verified reply to the bot " "is pending reply" @@ -347,7 +347,7 @@ def resolve_quota_run_decision( self_repair_allowed = False capability_repair_allowed = False workspace_repair_allowed = False - effective_action = "operator_inbox_material_review_due" + effective_action = EffectiveAction.OPERATOR_INBOX_MATERIAL_REVIEW_DUE.value reason = ( "captured unaddressed operator-inbox material is pending bounded review" ) @@ -386,29 +386,29 @@ def quota_effective_action( quota: dict[str, Any], ) -> str: if normal_delivery_allowed: - return "normal_run" + return EffectiveAction.NORMAL_RUN.value if recovery_delivery_allowed: - return "outcome_floor_recovery" + return EffectiveAction.OUTCOME_FLOOR_RECOVERY.value if workspace_repair_allowed: - return "agent_workspace_repair" + return EffectiveAction.AGENT_WORKSPACE_REPAIR.value if self_repair_allowed: repair_action = ( stall_self_repair.get("effective_action") if isinstance(stall_self_repair, dict) else None ) - return str(repair_action or "control_plane_repair") + return str(repair_action or EffectiveAction.CONTROL_PLANE_REPAIR.value) if capability_repair_allowed: - return "capability_bridge_repair" + return EffectiveAction.CAPABILITY_BRIDGE_REPAIR.value if state == "operator_gate": - return "operator_gate_notify" + return EffectiveAction.OPERATOR_GATE_NOTIFY.value if state == "blocked_health": - return "blocked_health" + return EffectiveAction.BLOCKED_HEALTH.value if state == "throttled": - return "throttled_skip" + return EffectiveAction.THROTTLED_SKIP.value if state in {"focus_wait", "waiting"} or quota.get("focus_wait"): - return "blocked_wait" - return "quota_skip" + return EffectiveAction.BLOCKED_WAIT.value + return EffectiveAction.QUOTA_SKIP.value def _task_orchestration_effective_action( @@ -424,18 +424,18 @@ def _task_orchestration_effective_action( and str(contract.get("execution_state") or "ready") == "ready" and should_run and normal_delivery_allowed - and effective_action == "normal_run" + and effective_action == EffectiveAction.NORMAL_RUN.value ): if contract.get("mode") == "adaptive": return ( - "coordinate_task_bundle", + EffectiveAction.COORDINATE_TASK_BUNDLE.value, ( "the task coordinator may use admitted child lanes before its " "own worker-lane delivery" ), ) return ( - "coordinate_task_bundle", + EffectiveAction.COORDINATE_TASK_BUNDLE.value, ( "the explicitly selected task coordinator must activate or resume " "eligible peer lanes before doing its own worker-lane delivery" diff --git a/loopx/control_plane/quota/effective_action.generated.ts b/loopx/control_plane/quota/effective_action.generated.ts new file mode 100644 index 0000000000..e4126cecf7 --- /dev/null +++ b/loopx/control_plane/quota/effective_action.generated.ts @@ -0,0 +1,76 @@ +// Generated by scripts/generate_semantic_bindings.py; do not edit. +// Value owner: loopx/control_plane/quota/effective_action.py::EffectiveAction + +export const EFFECTIVE_ACTIONS = [ + "agent_monitor_only", + "agent_workspace_repair", + "automation_prompt_upgrade_required", + "autonomous_replan_required", + "boundary_projection_repair", + "capability_bridge_repair", + "control_plane_health_repair", + "control_plane_projection_repair", + "coordinate_task_bundle", + "external_evidence_observe", + "governed_capability_intent", + "heartbeat_receipt_write_failed", + "heartbeat_settled_skip", + "lark_inbox_reply_due", + "monitor_due", + "monitor_quiet_skip", + "normal_run", + "operator_inbox_material_review_due", + "outcome_floor_recovery", + "peer_coordination_blocked", + "quota_skip", + "runtime_user_gate_projection_repair", + "scoped_user_gate_fallback", + "state_projection_gap_repair", + "terminal_no_followup", + "todo_decision_scope_projection_repair", + "unsettled_host_turn_recovery", + "blocked_health", + "blocked_wait", + "control_plane_repair", + "operator_gate_notify", + "throttled_skip", +] as const; +export type EffectiveActionValue = (typeof EFFECTIVE_ACTIONS)[number]; + +export const EffectiveAction = { + AGENT_MONITOR_ONLY: EFFECTIVE_ACTIONS[0], + AGENT_WORKSPACE_REPAIR: EFFECTIVE_ACTIONS[1], + AUTOMATION_PROMPT_UPGRADE_REQUIRED: EFFECTIVE_ACTIONS[2], + AUTONOMOUS_REPLAN_REQUIRED: EFFECTIVE_ACTIONS[3], + BOUNDARY_PROJECTION_REPAIR: EFFECTIVE_ACTIONS[4], + CAPABILITY_BRIDGE_REPAIR: EFFECTIVE_ACTIONS[5], + CONTROL_PLANE_HEALTH_REPAIR: EFFECTIVE_ACTIONS[6], + CONTROL_PLANE_PROJECTION_REPAIR: EFFECTIVE_ACTIONS[7], + COORDINATE_TASK_BUNDLE: EFFECTIVE_ACTIONS[8], + EXTERNAL_EVIDENCE_OBSERVE: EFFECTIVE_ACTIONS[9], + GOVERNED_CAPABILITY_INTENT: EFFECTIVE_ACTIONS[10], + HEARTBEAT_RECEIPT_WRITE_FAILED: EFFECTIVE_ACTIONS[11], + HEARTBEAT_SETTLED_SKIP: EFFECTIVE_ACTIONS[12], + LARK_INBOX_REPLY_DUE: EFFECTIVE_ACTIONS[13], + MONITOR_DUE: EFFECTIVE_ACTIONS[14], + MONITOR_QUIET_SKIP: EFFECTIVE_ACTIONS[15], + NORMAL_RUN: EFFECTIVE_ACTIONS[16], + OPERATOR_INBOX_MATERIAL_REVIEW_DUE: EFFECTIVE_ACTIONS[17], + OUTCOME_FLOOR_RECOVERY: EFFECTIVE_ACTIONS[18], + PEER_COORDINATION_BLOCKED: EFFECTIVE_ACTIONS[19], + QUOTA_SKIP: EFFECTIVE_ACTIONS[20], + RUNTIME_USER_GATE_PROJECTION_REPAIR: EFFECTIVE_ACTIONS[21], + SCOPED_USER_GATE_FALLBACK: EFFECTIVE_ACTIONS[22], + STATE_PROJECTION_GAP_REPAIR: EFFECTIVE_ACTIONS[23], + TERMINAL_NO_FOLLOWUP: EFFECTIVE_ACTIONS[24], + TODO_DECISION_SCOPE_PROJECTION_REPAIR: EFFECTIVE_ACTIONS[25], + UNSETTLED_HOST_TURN_RECOVERY: EFFECTIVE_ACTIONS[26], + BLOCKED_HEALTH: EFFECTIVE_ACTIONS[27], + BLOCKED_WAIT: EFFECTIVE_ACTIONS[28], + CONTROL_PLANE_REPAIR: EFFECTIVE_ACTIONS[29], + OPERATOR_GATE_NOTIFY: EFFECTIVE_ACTIONS[30], + THROTTLED_SKIP: EFFECTIVE_ACTIONS[31], +} as const; + +import type { AgentScopeFrontierActionValue } from "../agents/agent_scope_frontier.generated.ts"; +export type QuotaEffectiveActionValue = EffectiveActionValue | AgentScopeFrontierActionValue; diff --git a/loopx/control_plane/quota/effective_action.py b/loopx/control_plane/quota/effective_action.py new file mode 100644 index 0000000000..a7eefdae83 --- /dev/null +++ b/loopx/control_plane/quota/effective_action.py @@ -0,0 +1,45 @@ +"""Canonical value domain for the decision-slot effective action. + +The Turn Envelope still carries a string for wire compatibility. This enum is +the owner of the finite value domain; callers may serialize ``.value`` while +the semantic drift smoke checks that new decision values are deliberate. +""" + +from __future__ import annotations + +from enum import Enum + + +class EffectiveAction(str, Enum): + AGENT_MONITOR_ONLY = "agent_monitor_only" + AGENT_WORKSPACE_REPAIR = "agent_workspace_repair" + AUTOMATION_PROMPT_UPGRADE_REQUIRED = "automation_prompt_upgrade_required" + AUTONOMOUS_REPLAN_REQUIRED = "autonomous_replan_required" + BOUNDARY_PROJECTION_REPAIR = "boundary_projection_repair" + CAPABILITY_BRIDGE_REPAIR = "capability_bridge_repair" + CONTROL_PLANE_HEALTH_REPAIR = "control_plane_health_repair" + CONTROL_PLANE_PROJECTION_REPAIR = "control_plane_projection_repair" + COORDINATE_TASK_BUNDLE = "coordinate_task_bundle" + EXTERNAL_EVIDENCE_OBSERVE = "external_evidence_observe" + GOVERNED_CAPABILITY_INTENT = "governed_capability_intent" + HEARTBEAT_RECEIPT_WRITE_FAILED = "heartbeat_receipt_write_failed" + HEARTBEAT_SETTLED_SKIP = "heartbeat_settled_skip" + LARK_INBOX_REPLY_DUE = "lark_inbox_reply_due" + MONITOR_DUE = "monitor_due" + MONITOR_QUIET_SKIP = "monitor_quiet_skip" + NORMAL_RUN = "normal_run" + OPERATOR_INBOX_MATERIAL_REVIEW_DUE = "operator_inbox_material_review_due" + OUTCOME_FLOOR_RECOVERY = "outcome_floor_recovery" + PEER_COORDINATION_BLOCKED = "peer_coordination_blocked" + QUOTA_SKIP = "quota_skip" + RUNTIME_USER_GATE_PROJECTION_REPAIR = "runtime_user_gate_projection_repair" + SCOPED_USER_GATE_FALLBACK = "scoped_user_gate_fallback" + STATE_PROJECTION_GAP_REPAIR = "state_projection_gap_repair" + TERMINAL_NO_FOLLOWUP = "terminal_no_followup" + TODO_DECISION_SCOPE_PROJECTION_REPAIR = "todo_decision_scope_projection_repair" + UNSETTLED_HOST_TURN_RECOVERY = "unsettled_host_turn_recovery" + BLOCKED_HEALTH = "blocked_health" + BLOCKED_WAIT = "blocked_wait" + CONTROL_PLANE_REPAIR = "control_plane_repair" + OPERATOR_GATE_NOTIFY = "operator_gate_notify" + THROTTLED_SKIP = "throttled_skip" diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index 26b88a7a87..f902b35fd9 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import json from collections.abc import Mapping @@ -462,7 +463,7 @@ def fail_heartbeat_receipt( "ok": False, "decision": "skip", "should_run": False, - "effective_action": "heartbeat_receipt_write_failed", + "effective_action": EffectiveAction.HEARTBEAT_RECEIPT_WRITE_FAILED.value, "state": "blocked_health", "waiting_on": "codex", "reason": reason, diff --git a/loopx/control_plane/quota/host_poll_receipts.py b/loopx/control_plane/quota/host_poll_receipts.py index 83e9c79c69..5657699cd9 100644 --- a/loopx/control_plane/quota/host_poll_receipts.py +++ b/loopx/control_plane/quota/host_poll_receipts.py @@ -12,6 +12,7 @@ """ from __future__ import annotations +from .effective_action import EffectiveAction import json import os @@ -72,7 +73,7 @@ def record_host_poll_receipt( terminal_state = frontier.get("terminal_state") if isinstance(frontier, dict) else None terminal = bool( decision.get("should_run") is False - and decision.get("effective_action") == "terminal_no_followup" + and decision.get("effective_action") == EffectiveAction.TERMINAL_NO_FOLLOWUP.value and isinstance(terminal_state, dict) and terminal_state.get("kind") == "no_followup" ) diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index c9ac6427b6..62a5a6ef62 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import shlex from collections.abc import Callable, Mapping, Sequence @@ -191,7 +192,7 @@ def _apply_pending_capability_intent_precedence( "decision": "run", "should_run": True, "state": "eligible", - "effective_action": "governed_capability_intent", + "effective_action": EffectiveAction.GOVERNED_CAPABILITY_INTENT.value, "actionable_by_codex": True, "normal_delivery_allowed": False, "recovery_delivery_allowed": False, diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts index 5a80f8dd4c..19ea7e1750 100644 --- a/loopx/control_plane/quota/monitor_poll_commit.ts +++ b/loopx/control_plane/quota/monitor_poll_commit.ts @@ -1,3 +1,5 @@ +import { EffectiveAction, type QuotaEffectiveActionValue } from "./effective_action.generated.ts"; +import { AgentScopeFrontierAction } from "../agents/agent_scope_frontier.generated.ts"; import { createHash } from "node:crypto"; import { access, readFile, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; @@ -454,9 +456,12 @@ function exactBlockedWait(decision: MonitorDecision): JsonObject | null { } function blockedSuccessorAllowed(decision: MonitorDecision): boolean { - return ["agent_scope_wait", "monitor_quiet_skip"].includes( + return ([ + AgentScopeFrontierAction.AGENT_SCOPE_WAIT, EffectiveAction.MONITOR_QUIET_SKIP, + ] satisfies readonly QuotaEffectiveActionValue[] as readonly string[]).includes( decision.effective_action ?? "", - ) && !decision.should_run && !decision.requires_user_action && exactBlockedWait(decision) !== null; + ) && !decision.should_run && + !decision.requires_user_action && exactBlockedWait(decision) !== null; } function externalMonitorAllowed(decision: MonitorDecision): boolean { @@ -510,7 +515,7 @@ function admission(request: MonitorRequest): Admission { const external = externalMonitorAllowed(request.decision); const due = dueMonitorAllowed(request.decision, request.observation); if ( - request.decision.effective_action !== "monitor_quiet_skip" && + request.decision.effective_action !== EffectiveAction.MONITOR_QUIET_SKIP && !external && !due && !blocked ) { throw new EffectRuntimeRequestError( diff --git a/loopx/control_plane/quota/projection_repair.py b/loopx/control_plane/quota/projection_repair.py index df427715aa..fb201de35c 100644 --- a/loopx/control_plane/quota/projection_repair.py +++ b/loopx/control_plane/quota/projection_repair.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import fnmatch from typing import Any @@ -108,7 +109,7 @@ def build_state_projection_gap_repair_hint( "source": "quota.should-run", "trigger": "state_projection_gap", "recommended_mode": "repair_state_projection_gap", - "effective_action": "state_projection_gap_repair", + "effective_action": EffectiveAction.STATE_PROJECTION_GAP_REPAIR.value, "allowed": True, "notify": "DONT_NOTIFY", "reason": ( @@ -204,7 +205,7 @@ def build_boundary_projection_repair_hint( "source": "quota.should-run", "trigger": "required_write_scope_missing_from_goal_boundary", "recommended_mode": "repair_boundary_projection", - "effective_action": "boundary_projection_repair", + "effective_action": EffectiveAction.BOUNDARY_PROJECTION_REPAIR.value, "blocked_action_scope": "boundary_projection", "allowed": True, "notify": "DONT_NOTIFY", diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index 9bc0acc544..4722bcdd74 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -1,6 +1,7 @@ """CLI rollout helpers for heartbeat settlement identity and receipt wiring.""" from __future__ import annotations +from .effective_action import EffectiveAction import argparse from collections.abc import Mapping @@ -193,7 +194,7 @@ def quota_rollout_settlement_binding( packet is only a diagnostic fallback when no concrete Todo is selected. """ - if payload.get("effective_action") == "unsettled_host_turn_recovery": + if payload.get("effective_action") == EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value: # This Turn only repairs the preceding Turn's closeout. A concurrently # projected Todo or autonomous replan belongs to the post-recovery # decision and must not become this receipt's settlement identity. diff --git a/loopx/control_plane/quota/settlement_precedence.py b/loopx/control_plane/quota/settlement_precedence.py index c87173c6e1..f1cf97b179 100644 --- a/loopx/control_plane/quota/settlement_precedence.py +++ b/loopx/control_plane/quota/settlement_precedence.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from typing import Any, Protocol @@ -77,7 +78,7 @@ def apply_settled_replay_route_precedence( route.capability_repair_allowed = False route.workspace_repair_allowed = False route.should_run = False - route.effective_action = "heartbeat_settled_skip" + route.effective_action = EffectiveAction.HEARTBEAT_SETTLED_SKIP.value route.reason = HEARTBEAT_SETTLED_REPLAY_REASON route.replan_decision_allowed = False route.receipt_bound_replan_decision = False @@ -127,7 +128,7 @@ def apply_settled_replay_payload_precedence( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "heartbeat_settled_skip", + "effective_action": EffectiveAction.HEARTBEAT_SETTLED_SKIP.value, "actionable_by_codex": False, "reason": reason, "requires_user_action": False, diff --git a/loopx/control_plane/quota/should_run.py b/loopx/control_plane/quota/should_run.py index a8f4a265ab..66f052a7c7 100644 --- a/loopx/control_plane/quota/should_run.py +++ b/loopx/control_plane/quota/should_run.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from collections.abc import Callable, Mapping from pathlib import Path @@ -173,7 +174,7 @@ def build_quota_paused_should_run_payload( } execution_obligation = _execution_obligation( should_run=False, - effective_action="quota_skip", + effective_action=EffectiveAction.QUOTA_SKIP.value, heartbeat_recommendation=heartbeat_recommendation, ) payload: dict[str, Any] = { @@ -188,7 +189,7 @@ def build_quota_paused_should_run_payload( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "quota_skip", + "effective_action": EffectiveAction.QUOTA_SKIP.value, "actionable_by_codex": False, "reason": reason, "quota": quota, diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index ab7eb108d0..631a5c7619 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from .effective_action import EffectiveAction from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -8,8 +8,6 @@ from ...long_task_cadence import reconcile_long_task_cadence_hint from ...state_projection import ( next_action_projection_warning, -) -from ...state_projection import ( state_action_projection_warning as build_state_action_projection_warning, ) from .. import compact_control_plane_policy @@ -28,6 +26,7 @@ _attach_agent_identity_contracts, ) from ..agents.capability_gate import missing_required_capabilities +from ..agents.agent_scope_frontier import read_frontier_action from ..goals.goal_frontier import ( AUTONOMOUS_REPLAN_REQUIRED_MODE, ) @@ -349,7 +348,7 @@ def _apply_agent_monitor_only_precedence( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "monitor_due" if monitor_due else "monitor_quiet_skip", + "effective_action": EffectiveAction.MONITOR_DUE.value if monitor_due else EffectiveAction.MONITOR_QUIET_SKIP.value, "actionable_by_codex": monitor_due, "reason": reason, "blocked_action_scope": "advancement_work", @@ -397,7 +396,7 @@ def _apply_agent_monitor_only_precedence( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "agent_monitor_only", + "effective_action": EffectiveAction.AGENT_MONITOR_ONLY.value, "actionable_by_codex": False, "reason": reason, "blocked_action_scope": "advancement_work", @@ -745,7 +744,7 @@ def _planning_projections( and prepared.workspace_guard and prepared.normal_delivery_allowed ) or bool( - route.effective_action == "boundary_projection_repair" + route.effective_action == EffectiveAction.BOUNDARY_PROJECTION_REPAIR.value and prepared.boundary_projection_repair ) projection_enabled = bool( @@ -893,7 +892,7 @@ def _resolve_quota_should_run_route( "spend_policy": external_evidence_observation.get("spend_policy") or heartbeat_recommendation.get("spend_policy"), } - effective_action = "external_evidence_observe" + effective_action = EffectiveAction.EXTERNAL_EVIDENCE_OBSERVE.value reason = "external evidence monitor requires read-only observation before quiet no-op" receipt_bound_monitor_settled = ( work_lane_contract_is_receipt_bound_monitor_settled( @@ -905,7 +904,7 @@ def _resolve_quota_should_run_route( recovery_allowed = False self_repair_allowed = False should_run = False - effective_action = "heartbeat_settled_skip" + effective_action = EffectiveAction.HEARTBEAT_SETTLED_SKIP.value reason = ( "the receipt-bound monitor poll and required settlement receipts are " "complete for this heartbeat turn; defer successor selection to a new turn" @@ -934,7 +933,7 @@ def _resolve_quota_should_run_route( if monitor_quiet_skip: normal_delivery_allowed = False should_run = False - effective_action = "monitor_quiet_skip" + effective_action = EffectiveAction.MONITOR_QUIET_SKIP.value reason = str( heartbeat_recommendation.get("reason") or "monitor-only polling has no material transition; skip delivery compute" @@ -1015,7 +1014,7 @@ def _resolve_quota_should_run_route( if agent_scope_frontier and agent_lane_frontier_hint: agent_scope_frontier["frontier_hint"] = agent_lane_frontier_hint if agent_scope_frontier: - frontier_action = str(agent_scope_frontier.get("effective_action") or "") + frontier_action = read_frontier_action(agent_scope_frontier) successor_replan_required = ( frontier_action == AgentScopeFrontierAction.SUCCESSOR_REPLAN_REQUIRED.value @@ -1040,7 +1039,7 @@ def _resolve_quota_should_run_route( prepared.task_orchestration_contract, effective_action=effective_action, ): - effective_action = PEER_COORDINATION_BLOCKED_ACTION + effective_action = EffectiveAction.PEER_COORDINATION_BLOCKED.value reason = ( "the explicitly selected peer task bundle is blocked and the " "coordinator has no in-scope runnable fallback; return control " diff --git a/loopx/control_plane/quota/slot_accounting.py b/loopx/control_plane/quota/slot_accounting.py index 6819f75d82..bc9088e840 100644 --- a/loopx/control_plane/quota/slot_accounting.py +++ b/loopx/control_plane/quota/slot_accounting.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import json from collections.abc import Callable, Iterable @@ -606,13 +607,13 @@ def build_quota_slot_preview_for_decision( ( before.get("state") == "operator_gate" or before.get("recovery_delivery_allowed") is True - or before.get("effective_action") == "outcome_floor_recovery" + or before.get("effective_action") == EffectiveAction.OUTCOME_FLOOR_RECOVERY.value ) and before.get("safe_bypass_allowed") is True ) self_repair_spend = before.get("effective_action") in self_repair_spend_actions capability_repair_spend = ( - before.get("effective_action") == "capability_bridge_repair" + before.get("effective_action") == EffectiveAction.CAPABILITY_BRIDGE_REPAIR.value and before.get("capability_repair_allowed") is True ) delivery_completion_run = delivery_completion_run or ( @@ -715,7 +716,7 @@ def build_quota_slot_preview_for_decision( } delivery_workspace_validated = bool(delivery_workspace) workspace_repair_no_spend = ( - before.get("effective_action") == "agent_workspace_repair" + before.get("effective_action") == EffectiveAction.AGENT_WORKSPACE_REPAIR.value and before.get("workspace_repair_allowed") is True and not delivery_workspace_validated ) @@ -745,13 +746,13 @@ def build_quota_slot_preview_for_decision( and ( settlement_identity is not None or not before.get("should_run") - or before.get("effective_action") == "external_evidence_observe" + or before.get("effective_action") == EffectiveAction.EXTERNAL_EVIDENCE_OBSERVE.value or ( - before.get("effective_action") == "agent_workspace_repair" + before.get("effective_action") == EffectiveAction.AGENT_WORKSPACE_REPAIR.value and delivery_workspace_validated ) ) - and before.get("effective_action") != "automation_prompt_upgrade_required" + and before.get("effective_action") != EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value and not safe_bypass_spend and str(before.get("state") or "") in {"waiting", "focus_wait", "operator_gate", "eligible"} ) diff --git a/loopx/control_plane/quota/spend_commit.ts b/loopx/control_plane/quota/spend_commit.ts index 95ea5e678f..484e37416f 100644 --- a/loopx/control_plane/quota/spend_commit.ts +++ b/loopx/control_plane/quota/spend_commit.ts @@ -1,3 +1,4 @@ +import { EffectiveAction, type EffectiveActionValue } from "./effective_action.generated.ts"; import { createHash } from "node:crypto"; import { basename, isAbsolute, join } from "node:path"; @@ -44,12 +45,12 @@ export const QUOTA_SPEND_SOURCES = [ ] as const; export type QuotaSpendSource = (typeof QUOTA_SPEND_SOURCES)[number]; -const SELF_REPAIR_SPEND_ACTIONS = new Set([ - "control_plane_health_repair", - "control_plane_projection_repair", - "state_projection_gap_repair", - "boundary_projection_repair", - "todo_decision_scope_projection_repair", +const SELF_REPAIR_SPEND_ACTIONS: ReadonlySet = new Set([ + EffectiveAction.CONTROL_PLANE_HEALTH_REPAIR, + EffectiveAction.CONTROL_PLANE_PROJECTION_REPAIR, + EffectiveAction.STATE_PROJECTION_GAP_REPAIR, + EffectiveAction.BOUNDARY_PROJECTION_REPAIR, + EffectiveAction.TODO_DECISION_SCOPE_PROJECTION_REPAIR, ]); type QuotaSpendCommitStatus = @@ -364,25 +365,25 @@ function spendDisposition(request: QuotaSpendCommitRequest): SpendDisposition { action !== null && SELF_REPAIR_SPEND_ACTIONS.has(action) && request.before.self_repair_allowed; const capabilityRepairSpend = request.before.should_run && - action === "capability_bridge_repair" && + action === EffectiveAction.CAPABILITY_BRIDGE_REPAIR && request.before.capability_repair_allowed; const eligibleSpend = request.before.should_run && request.before.state === "eligible" && - action !== "external_evidence_observe" && + action !== EffectiveAction.EXTERNAL_EVIDENCE_OBSERVE && !selfRepairSpend && !capabilityRepairSpend && !request.before.workspace_repair_allowed && !deliveryCompletionSpend; const safeBypassSpend = request.preview.safe_bypass_spend === true && ( request.before.state === "operator_gate" || request.before.recovery_delivery_allowed || - action === "outcome_floor_recovery" + action === EffectiveAction.OUTCOME_FLOOR_RECOVERY ) && request.before.safe_bypass_allowed; // A recovered settlement describes work that already happened. The current // frontier may now ask for capability or control-plane repair, but that later // projection cannot rewrite the attribution of the completed delivery. if (deliveryCompletionSpend) return "delivery_completion"; if (eligibleSpend) return "eligible"; - if (safeBypassSpend && action === "outcome_floor_recovery") { + if (safeBypassSpend && action === EffectiveAction.OUTCOME_FLOOR_RECOVERY) { return "outcome_floor_recovery"; } if (selfRepairSpend) return "control_plane_self_repair"; diff --git a/loopx/control_plane/quota/stall_repair.py b/loopx/control_plane/quota/stall_repair.py index 66463c8415..58f7568611 100644 --- a/loopx/control_plane/quota/stall_repair.py +++ b/loopx/control_plane/quota/stall_repair.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from typing import Any @@ -216,7 +217,7 @@ def build_runtime_capability_user_gate_repair_hint( "trigger": RUNTIME_CAPABILITY_USER_GATE_REPAIR_TRIGGER, "schema_version": "runtime_capability_user_gate_repair_v0", "recommended_mode": "repair_user_gate_projection", - "effective_action": "runtime_user_gate_projection_repair", + "effective_action": EffectiveAction.RUNTIME_USER_GATE_PROJECTION_REPAIR.value, "blocked_action_scope": "user_gate_projection", "allowed": True, "notify": "DONT_NOTIFY", @@ -297,7 +298,7 @@ def build_quota_stall_self_repair_hint( "source": "quota.should-run", "trigger": "health_blocker", "recommended_mode": "repair_control_plane_health", - "effective_action": "control_plane_health_repair", + "effective_action": EffectiveAction.CONTROL_PLANE_HEALTH_REPAIR.value, "allowed": True, "notify": "DONT_NOTIFY", "reason": ( @@ -334,7 +335,7 @@ def build_quota_stall_self_repair_hint( "source": "quota.should-run", "trigger": "waiting_without_owner_projection", "recommended_mode": "repair_waiting_projection", - "effective_action": "control_plane_projection_repair", + "effective_action": EffectiveAction.CONTROL_PLANE_PROJECTION_REPAIR.value, "allowed": True, "notify": "DONT_NOTIFY", "reason": ( diff --git a/loopx/control_plane/quota/task_orchestration.py b/loopx/control_plane/quota/task_orchestration.py index 646a8e2f1b..8e8312c13f 100644 --- a/loopx/control_plane/quota/task_orchestration.py +++ b/loopx/control_plane/quota/task_orchestration.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from .effective_action import EffectiveAction from typing import Any from ..agents.agent_scope_frontier import AgentScopeFrontierAction @@ -22,7 +22,7 @@ AgentScopeFrontierAction.REASSIGNMENT_REQUIRED.value, } PEER_AGENT_ACTIVATION_CAPABILITY = "peer_agent_activation" -PEER_COORDINATION_BLOCKED_ACTION = "peer_coordination_blocked" +PEER_COORDINATION_BLOCKED_ACTION = EffectiveAction.PEER_COORDINATION_BLOCKED.value def task_orchestration_contract_is_actionable( @@ -94,7 +94,7 @@ def payload_work_lane_contract( recovery_allowed: bool, agent_scope_frontier: dict[str, Any] | None, ) -> dict[str, Any] | None: - if recovery_allowed and effective_action == "outcome_floor_recovery": + if recovery_allowed and effective_action == EffectiveAction.OUTCOME_FLOOR_RECOVERY.value: return None if not isinstance(work_lane_contract, dict): return work_lane_contract diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index e3d3c0f453..6756491cc7 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1,3 +1,4 @@ +import { EffectiveAction } from "./effective_action.generated.ts"; import { createHash } from "node:crypto"; import { @@ -575,7 +576,7 @@ function actionProjection(payload: JsonObject, protocolActionFields: JsonObject) const interaction = object(payload.interaction_contract); const agentChannel = object(interaction.agent_channel); const cliChannel = object(interaction.cli_channel); - const capabilityIntent = payload.effective_action === "governed_capability_intent" + const capabilityIntent = payload.effective_action === EffectiveAction.GOVERNED_CAPABILITY_INTENT ? projectPendingCapabilityIntent(payload.pending_capability_intent) : null; // A governed capability action has already won the live decision. Stale // replan/host-reentry projections must not replace its exact command. diff --git a/loopx/control_plane/quota/unsettled_host_turn.py b/loopx/control_plane/quota/unsettled_host_turn.py index b867a36add..ef12753ae8 100644 --- a/loopx/control_plane/quota/unsettled_host_turn.py +++ b/loopx/control_plane/quota/unsettled_host_turn.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from collections.abc import Mapping from pathlib import Path @@ -227,7 +228,7 @@ def apply_unsettled_host_turn_recovery_if_required( "decision": "unsettled_host_turn_recovery", "should_run": True, "state": "eligible", - "effective_action": "unsettled_host_turn_recovery", + "effective_action": EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value, "actionable_by_codex": True, "normal_delivery_allowed": False, "recovery_delivery_allowed": False, diff --git a/loopx/control_plane/scheduler/automation_liveness.py b/loopx/control_plane/scheduler/automation_liveness.py index e821c68592..ffd8dde899 100644 --- a/loopx/control_plane/scheduler/automation_liveness.py +++ b/loopx/control_plane/scheduler/automation_liveness.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..quota.effective_action import EffectiveAction from typing import Any @@ -64,7 +65,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: "next_trigger": "explicit quota resume with quota.compute > 0", "spend_policy": "no quota spend for paused automation shutdown", } - if effective_action == "agent_monitor_only": + if effective_action == EffectiveAction.AGENT_MONITOR_ONLY.value: return { **base, "keep_active": True, @@ -81,7 +82,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: ), "spend_policy": "no quota spend without a validated material transition", } - if effective_action == "terminal_no_followup": + if effective_action == EffectiveAction.TERMINAL_NO_FOLLOWUP.value: return { **base, "keep_active": False, @@ -99,7 +100,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: "spend_policy": "no quota spend for terminal automation shutdown", } if ( - effective_action == "monitor_quiet_skip" + effective_action == EffectiveAction.MONITOR_QUIET_SKIP.value or recommended_mode == "monitor_quiet_until_material_transition" ): return { @@ -115,7 +116,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: ), "spend_policy": "no quota spend for unchanged monitor-only polls", } - if effective_action == "heartbeat_settled_skip": + if effective_action == EffectiveAction.HEARTBEAT_SETTLED_SKIP.value: return { **base, "automation_action": "keep_active_quiet", @@ -126,7 +127,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: "next_trigger": "next heartbeat turn with a fresh turn identity", "spend_policy": "no quota spend for an already-settled heartbeat turn", } - if effective_action == "automation_prompt_upgrade_required": + if effective_action == EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value: return { **base, "automation_action": "repair_automation_prompt_identity", diff --git a/loopx/control_plane/testing/action_portfolio_scenarios.py b/loopx/control_plane/testing/action_portfolio_scenarios.py index f872ec855b..d7debbcbde 100644 --- a/loopx/control_plane/testing/action_portfolio_scenarios.py +++ b/loopx/control_plane/testing/action_portfolio_scenarios.py @@ -4,6 +4,7 @@ from typing import Any from ..quota.cli_projection import compact_quota_should_run_cli_payload +from ..quota.effective_action import EffectiveAction from ..quota.should_run import build_quota_should_run from ..work_items.interaction_contract import build_interaction_contract from ..quota.turn_envelope import quota_action_signature_document @@ -347,7 +348,7 @@ def turn_scenario_source( "goal_id": ACTUAL_DEFAULT_MODEL_BEHAVIOR_FIXTURE_GOAL_ID, "decision": "skip" if human_gate else "run", "should_run": not human_gate, - "effective_action": "operator_gate" if human_gate else "normal_run", + "effective_action": EffectiveAction.OPERATOR_GATE_NOTIFY.value if human_gate else EffectiveAction.NORMAL_RUN.value, "state": "operator_gate" if human_gate else "eligible", "requires_user_action": human_gate, "gate_prompt": ("Approve the bounded public release." if human_gate else None), diff --git a/loopx/control_plane/testing/host_prompt_behavior.py b/loopx/control_plane/testing/host_prompt_behavior.py index b80d8eaae6..b90daad821 100644 --- a/loopx/control_plane/testing/host_prompt_behavior.py +++ b/loopx/control_plane/testing/host_prompt_behavior.py @@ -6,6 +6,7 @@ from pathlib import Path from ...heartbeat_prompt import build_heartbeat_prompt +from ..quota.effective_action import EffectiveAction from .model_tool_behavior import DoubaoExecToolClient @@ -25,7 +26,10 @@ def cases() -> list[dict]: "packet": { "ok": True, "should_run": work, - "effective_action": "autonomous_replan_required" if replan else "run" if work else "wait", + "effective_action": ( + EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED.value if replan + else EffectiveAction.NORMAL_RUN.value if work else EffectiveAction.QUOTA_SKIP.value + ), "execution_obligation": {"must_attempt_work": work}, "heartbeat_recommendation": {"agent_must_attempt": work}, "autonomous_replan_obligation": {"required": replan}, diff --git a/loopx/control_plane/testing/replan_semantic_action_behavior.py b/loopx/control_plane/testing/replan_semantic_action_behavior.py index 9d30666335..0f08ffc89c 100644 --- a/loopx/control_plane/testing/replan_semantic_action_behavior.py +++ b/loopx/control_plane/testing/replan_semantic_action_behavior.py @@ -11,6 +11,7 @@ from typing import Any from ...heartbeat_prompt import build_heartbeat_prompt +from ..quota.effective_action import EffectiveAction from ..quota.turn_envelope import quota_action_signature_document from ..work_items.progress_observation import ( ProgressResultClass, @@ -561,7 +562,7 @@ def _successor_reentry_observation( raise ValueError("successor_reentry_replan_not_closed") if not ( packet.get("decision") == "run" - and packet.get("effective_action") == "normal_run" + and packet.get("effective_action") == EffectiveAction.NORMAL_RUN.value ): raise ValueError("successor_reentry_not_runnable") @@ -622,7 +623,7 @@ def _semantic_reentry_observation( else None ) future_monitor_wait = bool( - packet.get("effective_action") == "monitor_quiet_skip" + packet.get("effective_action") == EffectiveAction.MONITOR_QUIET_SKIP.value and isinstance(frontier, Mapping) and frontier.get("replan_required") is False and isinstance(monitor_lanes, Mapping) diff --git a/loopx/control_plane/todos/decision_scope.py b/loopx/control_plane/todos/decision_scope.py index 2d3b2ef3de..5c49271f5f 100644 --- a/loopx/control_plane/todos/decision_scope.py +++ b/loopx/control_plane/todos/decision_scope.py @@ -1,5 +1,6 @@ """Legacy input codec for the single typed decision-dependency rule owner.""" from __future__ import annotations +from ..quota.effective_action import EffectiveAction from typing import Any @@ -235,7 +236,7 @@ def build_required_decision_scope_repair_hint( "source": "quota.should-run", "trigger": "user_gate_scope_projection_drift", "recommended_mode": "repair_user_gate_scope_projection", - "effective_action": "todo_decision_scope_projection_repair", + "effective_action": EffectiveAction.TODO_DECISION_SCOPE_PROJECTION_REPAIR.value, "blocked_action_scope": "todo_user_gate_scope_projection", "allowed": True, "notify": "DONT_NOTIFY", @@ -257,7 +258,7 @@ def build_required_decision_scope_repair_hint( "source": "quota.should-run", "trigger": "required_decision_scope_projection_drift", "recommended_mode": "repair_required_decision_scope_projection", - "effective_action": "todo_decision_scope_projection_repair", + "effective_action": EffectiveAction.TODO_DECISION_SCOPE_PROJECTION_REPAIR.value, "blocked_action_scope": "todo_decision_scope_projection", "allowed": True, "notify": "DONT_NOTIFY", diff --git a/loopx/control_plane/todos/user_gate.py b/loopx/control_plane/todos/user_gate.py index 41bfb042df..3a52355c6b 100644 --- a/loopx/control_plane/todos/user_gate.py +++ b/loopx/control_plane/todos/user_gate.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..quota.effective_action import EffectiveAction from typing import Any @@ -159,8 +160,8 @@ def apply_scoped_user_gate_fallback_projection( projected["should_run"] = True if projected.get("decision") == "skip": projected["decision"] = "safe_bypass_user_gate_fallback" - if projected.get("effective_action") in {"skip", "monitor_quiet_skip", None}: - projected["effective_action"] = "scoped_user_gate_fallback" + if projected.get("effective_action") in {EffectiveAction.QUOTA_SKIP.value, EffectiveAction.MONITOR_QUIET_SKIP.value, None}: + projected["effective_action"] = EffectiveAction.SCOPED_USER_GATE_FALLBACK.value raw_execution_obligation = projected.get("execution_obligation") execution_obligation = ( diff --git a/loopx/control_plane/turn_driver/driver.py b/loopx/control_plane/turn_driver/driver.py index acd414495e..ef2180ea4d 100644 --- a/loopx/control_plane/turn_driver/driver.py +++ b/loopx/control_plane/turn_driver/driver.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..quota.effective_action import EffectiveAction import json from collections.abc import Mapping @@ -96,7 +97,7 @@ def _typed_route(envelope: Mapping[str, Any]) -> LoopXTurnRoute: if should_run: if not delivery_allowed or not must_attempt: return LoopXTurnRoute.BLOCKED - if effective_action == "governed_capability_intent": + if effective_action == EffectiveAction.GOVERNED_CAPABILITY_INTENT.value: intent = _mapping(action.get("capability_intent")) if (intent.get("schema_version") != "pending_capability_intent_projection_v0" or intent.get("goal_id") != envelope.get("goal_id") diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 3e8d0dfa5d..41bdeb7bd9 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -1,3 +1,4 @@ +import { EffectiveAction } from "../quota/effective_action.generated.ts"; import { createHash } from "node:crypto"; import { @@ -590,7 +591,7 @@ function guardSelection(value: string): GuardSelection { } if ( guard.should_run === false && - guard.effective_action === "terminal_no_followup" + guard.effective_action === EffectiveAction.TERMINAL_NO_FOLLOWUP ) { return { state: "terminal_no_selection", diff --git a/loopx/control_plane/turn_driver/loop_controller.py b/loopx/control_plane/turn_driver/loop_controller.py index b5d196107e..e3af43e19b 100644 --- a/loopx/control_plane/turn_driver/loop_controller.py +++ b/loopx/control_plane/turn_driver/loop_controller.py @@ -18,6 +18,7 @@ """ from __future__ import annotations +from ..quota.effective_action import EffectiveAction from collections.abc import Mapping from enum import Enum @@ -437,7 +438,7 @@ def _completion_disposition( continuation = str(completion.get("continuation") or "") if continuation == "no_followup": if ( - decision.get("effective_action") != "terminal_no_followup" + decision.get("effective_action") != EffectiveAction.TERMINAL_NO_FOLLOWUP.value or decision.get("state") != "terminal_no_followup" ): raise ValueError( @@ -529,7 +530,7 @@ def decide_loop_disposition( ) if turn_receipt is None: - if str(quota_decision.get("effective_action") or "") == "terminal_no_followup": + if str(quota_decision.get("effective_action") or "") == EffectiveAction.TERMINAL_NO_FOLLOWUP.value: if quota_decision.get("state") != "terminal_no_followup": raise ValueError( "terminal no-follow-up requires fresh Goal frontier state" diff --git a/loopx/control_plane/turn_driver/turn_journal.ts b/loopx/control_plane/turn_driver/turn_journal.ts index 807e639882..2ae7fa80c0 100644 --- a/loopx/control_plane/turn_driver/turn_journal.ts +++ b/loopx/control_plane/turn_driver/turn_journal.ts @@ -7,6 +7,7 @@ import { SETTLEMENT_IDENTITY_SCHEMA_VERSION, SETTLEMENT_PLAN_SCHEMA_VERSION, settlementIdentityFromPlan, + type EffectObservation, type EffectTurn, } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; @@ -93,10 +94,14 @@ export interface TurnJournalEffectContext { last_recovery: TurnRecoveryAudit | null; } -export type TurnJournalEffect = EffectTurn< +// Replay has its own verdict. It is not a quota decision and must not manufacture +// a second action vocabulary in the should-run effective_action slot. +export type TurnJournalEffect = Omit; +>, "observation"> & { + observation: Omit, "effective_action">; +}; export const transactionPhases = Object.freeze([...transactionContract.phases]); export const supportedJournalStatuses: ReadonlySet = new Set([ @@ -653,7 +658,6 @@ export function interpretTurnJournalEffect( observation: { decision, should_run: false, - effective_action: replayLegal ? "observe_replay" : "block_replay", recommended_action: replayLegal ? "Retain the terminal Turn journal tombstone." : "Inspect the structured Turn journal violations before replay.", diff --git a/loopx/control_plane/work_items/goal_route_hint.py b/loopx/control_plane/work_items/goal_route_hint.py index fe4a775b73..4080bdd551 100644 --- a/loopx/control_plane/work_items/goal_route_hint.py +++ b/loopx/control_plane/work_items/goal_route_hint.py @@ -3,6 +3,7 @@ from typing import Any from ...state_projection import actions_are_projection_aligned +from ..agents.agent_scope_frontier import read_frontier_action from ..todos.contract import ( TODO_STATUS_OPEN, TODO_TASK_CLASS_ADVANCEMENT, @@ -258,11 +259,7 @@ def build_goal_route_hint( route_decision = "run_current_agent_lane" reason = "quota selected a runnable current-agent lane todo" elif isinstance(agent_scope_frontier, dict): - route_decision = str( - agent_scope_frontier.get("effective_action") - or agent_scope_frontier.get("action") - or "agent_scope_frontier" - ) + route_decision = read_frontier_action(agent_scope_frontier) or "agent_scope_frontier" reason = str(agent_scope_frontier.get("reason") or "agent-scope frontier blocks delivery") elif blocking_handoff_gates or other_agent_actions: route_decision = "wait_or_reassign_other_agent_lane" diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index fccf313982..ef6818ecd6 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from ..quota.effective_action import EffectiveAction import shlex import typing from collections.abc import Mapping @@ -454,23 +454,23 @@ def _interaction_mode(payload: dict[str, Any]) -> str: kind = str(execution_obligation.get("kind") or "") effective_action = str(payload.get("effective_action") or "") state = str(payload.get("state") or "") - if effective_action == "governed_capability_intent": + if effective_action == EffectiveAction.GOVERNED_CAPABILITY_INTENT.value: return effective_action - if effective_action == "unsettled_host_turn_recovery": + if effective_action == EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value: return effective_action - if effective_action == "agent_monitor_only": + if effective_action == EffectiveAction.AGENT_MONITOR_ONLY.value: return "agent_monitor_only" - if effective_action == "monitor_due": + if effective_action == EffectiveAction.MONITOR_DUE.value: return "monitor_due" - if effective_action == "terminal_no_followup" or state == "terminal_no_followup": + if effective_action == EffectiveAction.TERMINAL_NO_FOLLOWUP.value or state == "terminal_no_followup": return "terminal_no_followup" - if effective_action == "peer_coordination_blocked": + if effective_action == EffectiveAction.PEER_COORDINATION_BLOCKED.value: return effective_action if payload.get("scoped_user_gate_fallback"): return "scoped_user_gate_fallback" if _user_gate_notification_suppressed(payload): return "user_gate_cooldown_wait" - if effective_action == "automation_prompt_upgrade_required": + if effective_action == EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value: return "automation_prompt_upgrade" if user_channel_action_required(payload): if ( @@ -495,22 +495,22 @@ def _interaction_mode(payload: dict[str, Any]) -> str: return "external_evidence_observation" if kind == AUTONOMOUS_REPLAN_REQUIRED_MODE: return "autonomous_replan" - if effective_action == "coordinate_task_bundle": + if effective_action == EffectiveAction.COORDINATE_TASK_BUNDLE.value: return "task_orchestration" agent_scope_action = _agent_scope_frontier_action(effective_action) if agent_scope_action is not None: return agent_scope_action.value - if effective_action == "monitor_quiet_skip": + if effective_action == EffectiveAction.MONITOR_QUIET_SKIP.value: return "monitor_quiet_skip" - if effective_action == "heartbeat_settled_skip": + if effective_action == EffectiveAction.HEARTBEAT_SETTLED_SKIP.value: return "heartbeat_settled_skip" - if payload.get("recovery_delivery_allowed") or effective_action == "outcome_floor_recovery": + if payload.get("recovery_delivery_allowed") or effective_action == EffectiveAction.OUTCOME_FLOOR_RECOVERY.value: return "outcome_floor_recovery" - if effective_action == "capability_bridge_repair": + if effective_action == EffectiveAction.CAPABILITY_BRIDGE_REPAIR.value: return "capability_bridge_repair" - if effective_action == "agent_workspace_repair": + if effective_action == EffectiveAction.AGENT_WORKSPACE_REPAIR.value: return effective_action - if effective_action == "boundary_projection_repair": + if effective_action == EffectiveAction.BOUNDARY_PROJECTION_REPAIR.value: return "boundary_projection_repair" if payload.get("self_repair_allowed"): return "control_plane_self_repair" diff --git a/loopx/ready_score.py b/loopx/ready_score.py index bcb13fa339..586ff9b996 100644 --- a/loopx/ready_score.py +++ b/loopx/ready_score.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .control_plane.quota.effective_action import EffectiveAction from typing import Any from urllib.parse import quote @@ -265,7 +266,7 @@ def build_ready_score_report( quota_points += 8 elif should_run: quota_points += 5 - if normal_allowed or effective_action == "normal_run": + if normal_allowed or effective_action == EffectiveAction.NORMAL_RUN.value: quota_points += 5 if scheduler_apply_needed is False: quota_points += 4 diff --git a/loopx/semantics/inventory_v0.json b/loopx/semantics/inventory_v0.json index 6a15b5e8f9..680322cd7b 100644 --- a/loopx/semantics/inventory_v0.json +++ b/loopx/semantics/inventory_v0.json @@ -63,6 +63,7 @@ {"name": "GoalFrontierReplanRule", "module": "loopx/control_plane/goals/goal_frontier/replan_rules.py", "values": ["existing_obligation", "blocking_handoff_gate", "ready_deferred_successor", "open_user_todo", "user_action_owns_empty_frontier", "todo_succession_gap", "vision_acceptance_gap", "long_todo_chain", "current_agent_blocker", "monitor_no_change_streak", "not_monitor_only", "no_open_monitor", "advancement_remains", "due_monitor_execution", "future_monitor_wait", "monitor_frontier_exhausted"]}, {"name": "GoalVisionAdvancementPolicy", "module": "loopx/control_plane/goals/goal_vision_policy.py", "values": ["as_needed", "repeat_until_closed"]}, {"name": "HostGuardState", "module": "loopx/control_plane/host_adapter_settlement.py", "values": ["selected", "terminal_no_selection", "invalid"]}, + {"name": "EffectiveAction", "module": "loopx/control_plane/quota/effective_action.py", "values": ["agent_monitor_only", "agent_workspace_repair", "automation_prompt_upgrade_required", "autonomous_replan_required", "boundary_projection_repair", "capability_bridge_repair", "control_plane_health_repair", "control_plane_projection_repair", "coordinate_task_bundle", "external_evidence_observe", "governed_capability_intent", "heartbeat_receipt_write_failed", "heartbeat_settled_skip", "lark_inbox_reply_due", "monitor_due", "monitor_quiet_skip", "normal_run", "operator_inbox_material_review_due", "outcome_floor_recovery", "peer_coordination_blocked", "quota_skip", "runtime_user_gate_projection_repair", "scoped_user_gate_fallback", "state_projection_gap_repair", "terminal_no_followup", "todo_decision_scope_projection_repair", "unsettled_host_turn_recovery", "blocked_health", "blocked_wait", "control_plane_repair", "operator_gate_notify", "throttled_skip"]}, {"name": "QuotaIdentityPrecondition", "module": "loopx/control_plane/quota/error_codes.py", "values": ["public_safe_agent_id", "registered_agent_roster_present", "requested_agent_registered"]}, {"name": "AutomaticTurnPauseCause", "module": "loopx/control_plane/quota/states.py", "values": ["goal_stopped", "compute_quota_zero"]}, {"name": "SchedulerDisposition", "module": "loopx/control_plane/scheduler/arbitration.py", "values": ["terminal_stop", "peer_coordination_stop", "agent_monitor_only_wait", "active_work", "agent_scope_wait", "consistency_repair", "human_gate", "monitor_wait", "quiet_wait", "unchanged_wait"]}, @@ -560,6 +561,9 @@ {"name": "ROLLOUT_EVENT_KINDS", "module": "loopx/rollout_event_log.py", "container": "set", "values": ["codex_session_observed", "capability_gap", "compact_blocker", "compact_case_result", "evidence_log_read", "failure_attribution", "pr_merge", "pr_review_ack", "quota_monitor_poll", "quota_should_run", "quota_spend", "quota_void", "refresh_state", "refresh_external_delivery", "research_evidence", "research_hypothesis", "todo_add", "todo_archive_completed", "todo_claim", "todo_complete", "todo_supersede", "todo_update", "validation"]}, {"name": "SKIP_PARTS", "module": "loopx/semantics/inventory.py", "container": "frozenset", "values": ["__pycache__", "node_modules"]}, {"name": "SOURCE_SUFFIXES", "module": "loopx/semantics/inventory.py", "container": "tuple", "values": [".py", ".ts"]}, + {"name": "PRODUCER_FILES", "module": "loopx/semantics/production.py", "container": "frozenset", "values": ["loopx/control_plane/effect_program.py", "loopx/control_plane/effect_program.ts"]}, + {"name": "PRODUCER_ROOTS", "module": "loopx/semantics/production.py", "container": "tuple", "values": ["loopx/cli_commands", "loopx/control_plane/agents", "loopx/control_plane/quota", "loopx/control_plane/todos", "loopx/control_plane/coordination", "loopx/control_plane/turn_driver", "loopx/control_plane/work_items"]}, + {"name": "QUOTA_ACTION_VOCABULARIES", "module": "loopx/semantics/production.py", "container": "tuple", "values": ["effective_action", "agent_scope_frontier_action"]}, {"name": "BLOCKED_STATUSES", "module": "loopx/session_runtime.py", "container": "set", "values": ["blocked", "error", "failed", "timed_out"]}, {"name": "COMPACT_SUFFIX_WORDS", "module": "loopx/session_runtime.py", "container": "frozenset", "values": ["id", "ids", "ref", "refs", "count", "at"]}, {"name": "EXPLICIT_COMPACT_COLLISION_KEYS", "module": "loopx/session_runtime.py", "container": "frozenset", "values": ["conversation_id", "log_count", "message_id", "prompt_token_count", "prompt_tokens", "trace_id"]}, @@ -614,6 +618,7 @@ ], "typescript_const_arrays": [ {"name": "AGENT_CONTEXT_PHASES", "module": "loopx/control_plane/agent_context.ts", "values": ["before_plan", "before_delegate", "after_delegate_result"]}, + {"name": "AGENT_SCOPE_FRONTIER_ACTIONS", "module": "loopx/control_plane/agents/agent_scope_frontier.generated.ts", "values": ["agent_scope_exhausted", "agent_scope_wait", "reassignment_required", "successor_replan_required"]}, {"name": "DELIVERY_WORKSPACE_IDENTITY_KINDS", "module": "loopx/control_plane/agents/delivery_workspace.ts", "values": ["git_repository", "local_goal"]}, {"name": "DELIVERY_WORKSPACE_KINDS", "module": "loopx/control_plane/agents/delivery_workspace.ts", "values": ["canonical_checkout", "independent_git_worktree", "local_goal_workspace"]}, {"name": "AUTHORITY_STORE_REQUIRED_GUARANTEES", "module": "loopx/control_plane/coordination/authority_store.ts", "values": ["atomic_event_projection_receipt_commit", "conditional_provider_revision", "durable_same_key_readback", "explicit_ambiguous_commit", "ordered_cursor_scan", "stable_store_lineage"]}, @@ -628,6 +633,7 @@ {"name": "REVISION_BASIS_VALUES", "module": "loopx/control_plane/goals/goal_amendment_proposal.ts", "values": ["state_event_log", "markdown_active_state", "canonical_todo_snapshot"]}, {"name": "SHARED_GOAL_ALIGNMENT_CONFLICT_FACTS", "module": "loopx/control_plane/goals/shared_goal_alignment.ts", "values": ["frontier_basis_unverifiable", "lease_owner_mismatch", "open_lane_replan_obligation", "peer_claimed_lane_conflict"]}, {"name": "SHARED_GOAL_ALIGNMENT_DRIFT_FACTS", "module": "loopx/control_plane/goals/shared_goal_alignment.ts", "values": ["frontier_basis_behind"]}, + {"name": "EFFECTIVE_ACTIONS", "module": "loopx/control_plane/quota/effective_action.generated.ts", "values": ["agent_monitor_only", "agent_workspace_repair", "automation_prompt_upgrade_required", "autonomous_replan_required", "boundary_projection_repair", "capability_bridge_repair", "control_plane_health_repair", "control_plane_projection_repair", "coordinate_task_bundle", "external_evidence_observe", "governed_capability_intent", "heartbeat_receipt_write_failed", "heartbeat_settled_skip", "lark_inbox_reply_due", "monitor_due", "monitor_quiet_skip", "normal_run", "operator_inbox_material_review_due", "outcome_floor_recovery", "peer_coordination_blocked", "quota_skip", "runtime_user_gate_projection_repair", "scoped_user_gate_fallback", "state_projection_gap_repair", "terminal_no_followup", "todo_decision_scope_projection_repair", "unsettled_host_turn_recovery", "blocked_health", "blocked_wait", "control_plane_repair", "operator_gate_notify", "throttled_skip"]}, {"name": "RECEIPT_BOUND_MONITOR_PHASES", "module": "loopx/control_plane/quota/settlement_phase.ts", "values": ["poll_due", "settlement_pending", "settled"]}, {"name": "RECEIPT_BOUND_REPLAY_PHASES", "module": "loopx/control_plane/quota/settlement_phase.ts", "values": ["open", "settlement_pending", "settled"]}, {"name": "DELIVERY_WORKSPACE_REQUIREMENTS", "module": "loopx/control_plane/quota/settlement_workspace_causality.ts", "values": ["required", "not_required", "unknown"]}, @@ -899,12 +905,12 @@ ] }, "summary": { - "source_files": 1180, - "python_enums": 103, - "python_closed_sets": 492, + "source_files": 1185, + "python_enums": 104, + "python_closed_sets": 495, "python_literal_aliases": 8, - "typescript_const_arrays": 40, - "named_string_constants": 2054, + "typescript_const_arrays": 42, + "named_string_constants": 2053, "schema_version_names": 756, "schema_version_same_runtime_forks": 7, "cross_runtime_twins": 166, diff --git a/loopx/semantics/production.py b/loopx/semantics/production.py new file mode 100644 index 0000000000..c3c708b4b2 --- /dev/null +++ b/loopx/semantics/production.py @@ -0,0 +1,219 @@ +"""Collect and check bounded semantic production evidence for repository CI.""" +from __future__ import annotations + +import ast +import json +from pathlib import Path +import subprocess +from typing import Any + +from .inventory import SourceFile +from .python_production import Production, enum_members, scan_python_production + + +# This source boundary is code owned. It is not adjustable through registry data. +PRODUCER_ROOTS = ( + 'loopx/cli_commands', 'loopx/control_plane/agents', + 'loopx/control_plane/quota', 'loopx/control_plane/todos', 'loopx/control_plane/coordination', + 'loopx/control_plane/turn_driver', 'loopx/control_plane/work_items', +) +PRODUCER_FILES = frozenset({ + 'loopx/control_plane/effect_program.py', 'loopx/control_plane/effect_program.ts', +}) + +# Root should-run actions preserve two disjoint, independently owned domains. +# Registry data cannot add another union arm to weaken field closedness. +QUOTA_ACTION_VOCABULARIES = ('effective_action', 'agent_scope_frontier_action') + + +def quota_action_domain(registry: dict[str, Any]) -> set[str]: + slots = [slot for entry in registry['relations']['shared_field_names'] + if entry.get('field') == 'effective_action' + for slot in entry['slots'] if slot.get('slot') == 'should_run.effective_action'] + if len(slots) != 1 or slots[0].get('vocabularies') != list(QUOTA_ACTION_VOCABULARIES): + raise ValueError('should_run.effective_action must retain its anchored decision/frontier union') + result: set[str] = set() + for name in QUOTA_ACTION_VOCABULARIES: + values = set(registry['vocabularies'][name]['values']) + if result & values: + raise ValueError('should_run.effective_action union arms must be disjoint') + result.update(values) + return result + + +def collect_production(root: Path, vocabulary: dict[str, Any], sources: list[SourceFile]) -> list[Production]: + by_path = {s.path: s for s in sources} + owner = vocabulary['owners'].get('python') + enums = {} + if owner: + module, symbol = owner.split('::') + if module not in by_path: + raise ValueError(f'producer owner must be a tracked source: {owner}') + enums[owner] = enum_members(by_path[module], symbol) + field = vocabulary.get('literal_scan', {}).get('field') + returns = vocabulary.get('return_producers', []) + return_paths = vocabulary.get('return_paths', {}) + if not set(return_paths) <= set(returns): + raise ValueError('return paths must name declared return producers') + if any(not isinstance(path, list) or not path or any(type(key) not in (str, int) for key in path) + for path in return_paths.values()): + raise ValueError('return paths must be nonempty literal field/index paths') + calls = _call_arguments(vocabulary.get('call_producers', {}), by_path) + selected = [s for s in sources if s.path in PRODUCER_FILES + or any(s.path.startswith(p + '/') for p in PRODUCER_ROOTS)] + rows = [] + for source in selected: + if source.suffix == '.py': + names = frozenset(site.split('::')[1] for site in returns if site.split('::')[0] == source.path) + paths = {site.split('::')[1]: tuple(path) for site, path in return_paths.items() + if site.split('::')[0] == source.path} + rows.extend(scan_python_production(source, field=field, enums=enums, return_functions=names, + return_paths=paths, call_arguments=calls)) + rows.extend(_typescript_scan(root, [s for s in selected if s.suffix == '.ts'], field, returns)) + return rows + + +def _call_arguments(declarations: dict[str, list[str]], sources: dict[str, SourceFile]) -> dict[str, dict[str, int | None]]: + """Bind reviewed output-builder parameters to their actual source signature. + + This is a finite caller contract, not interprocedural inference. The registry + names the output argument; tracked source proves the target/signature, and + the Python scanner proves an unshadowed local or imported call binding. + """ + calls = {} + for site, names in declarations.items(): + path, symbol = site.split('::') + if path not in sources or sources[path].suffix != '.py': + raise ValueError(f'call producer must name a tracked Python builder: {site}') + functions = [n for n in ast.parse(sources[path].text, filename=path).body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == symbol] + if len(functions) != 1: + raise ValueError(f'call producer requires one top-level builder: {site}') + args = functions[0].args + parameters: dict[str, int | None] = {arg.arg: i for i, arg in enumerate([*args.posonlyargs, *args.args])} + parameters.update({arg.arg: None for arg in args.kwonlyargs}) + if not names or len(set(names)) != len(names) or not set(names) <= parameters.keys(): + raise ValueError(f'call producer arguments must match the builder signature: {site}') + calls[site] = {name: parameters[name] for name in names} + return calls + + +def _typescript_scan( + root: Path, ts_sources: list[SourceFile], field: str | None, + returns: list[str], mode: str = 'production', +) -> list[Production]: + if not ts_sources or not field: + return [] + rows = [] + completed = subprocess.run( + ['node', str(root / 'scripts/semantic_production_scan.mjs')], + input=json.dumps({'field': field, 'sources': [{'path': s.path, 'text': s.text} for s in ts_sources], + 'return_functions': returns, 'mode': mode}), + capture_output=True, text=True, timeout=60, check=False, + ) + if completed.returncode: + # Accept only a bounded location from the parser, never echo source + # text or arbitrary subprocess stderr into public diagnostics. + try: + failure = json.loads(completed.stdout) + except json.JSONDecodeError: + failure = None + error = failure.get('error') if isinstance(failure, dict) else None + if (isinstance(error, dict) and error.get('code') == 'typescript_syntax' + and error.get('path') in {s.path for s in ts_sources} + and isinstance(error.get('line'), int) and error['line'] > 0): + raise ValueError(f"{error['path']}:{error['line']}: invalid TypeScript source; repair syntax before semantic scanning") + raise ValueError('TypeScript production parser failed; run npm ci --ignore-scripts and check the Node runtime') + rows.extend(Production(r['site'], r['line'], r['form'], frozenset(r['values']), r['unresolved']) + for r in json.loads(completed.stdout)) + return rows + + +def collect_literal_uses(root: Path, field: str, sources: list[SourceFile]) -> dict[str, set[str]]: + """Observe literal field writes/dispatch, with a deliberately bounded grammar.""" + from .python_production import python_literal_uses + + observed: dict[str, set[str]] = {} + for source in sources: + if source.suffix == '.py': + for value in python_literal_uses(source, field): + observed.setdefault(value, set()).add(source.path) + for row in _typescript_scan(root, [s for s in sources if s.suffix == '.ts'], field, [], 'literal_uses'): + for value in row.values: + observed.setdefault(value, set()).add(row.site.split('::')[0]) + return observed + + +def validate_production( + name: str, vocabulary: dict[str, Any], rows: list[Production], + *, field_domain: set[str] | None = None, +) -> list[str]: + """F1/F2 checks on observed results; owner members do not establish liveness. + + Unresolved sites are returned explicitly. Their unknown portion supplies + no value evidence; known output alternatives still count for closedness + and liveness. This function does not claim whole-program closedness. + """ + outputs = [row for row in rows if row.form != 'keyword_unproved'] + observed = set().union(*(row.values for row in outputs)) + expected = set(vocabulary['values']) + # A composed field domain never widens a canonical decision function's + # return type, and union arms cannot supply the owner's liveness evidence. + writes = {'assignment', 'dict', 'keyword', 'keyword_unproved', 'object'} + unregistered = set().union(*( + row.values - (field_domain if field_domain is not None and row.form in writes else expected) + for row in rows + )) + if unregistered: + sites = sorted({f'{r.site}:{r.line}' for r in rows if r.values & unregistered}) + role = 'producer writes' if any(r.values & unregistered for r in outputs) else 'field argument carries' + raise ValueError(f'{name}: {role} unregistered values {sorted(unregistered)} at {sites}') + compatibility = set(vocabulary.get('compatibility_only', {})) + if compatibility - expected: + raise ValueError(f'{name}: compatibility-only values must be registered') + if compatibility & observed: + raise ValueError(f'{name}: compatibility-only values are produced: {sorted(compatibility & observed)}') + missing = expected - observed - compatibility + if missing: + raise ValueError(f'{name}: values have no observed producer: {sorted(missing)}; owner definition is not production') + producers = vocabulary.get('producers', []) + declared = set(producers) + if len(declared) != len(producers): + raise ValueError(f'{name}: producer sites repeat') + returns = set(vocabulary.get('return_producers', [])) + if not returns <= declared: + raise ValueError(f'{name}: return producers must also be registered producers') + stale = sorted(declared - {row.site for row in outputs}) + if stale: + raise ValueError(f'{name}: producer sites have no observed write or return: {stale}') + undeclared = sorted({row.site for row in outputs if row.values and row.site not in declared}) + if undeclared: + raise ValueError(f'{name}: undeclared producer sites: {undeclared}') + return sorted({f'{row.site}:{row.line}' for row in rows if row.unresolved}) + + +def probe_turn_result_input_domain(vocabulary: dict[str, Any]) -> list[Production]: + """Witness this real decoder's finite output domain, not the host's traces. + + A successful call is evidence that the production function can emit a value + for a legal input. Merely enumerating the owner is not such evidence. The + callable is fixed in code; registry data cannot select arbitrary imports. + """ + from loopx.control_plane.turn_driver.transaction import LoopXTurnResultKind, _result_kind + + site = 'loopx/control_plane/turn_driver/transaction.py::_result_kind' + if vocabulary.get('input_producer') != site: + raise ValueError('turn_result_kind: input_producer must name the anchored decoder') + rows = [] + for value in vocabulary['values']: + errors: list[str] = [] + actual = _result_kind(value, errors) + if errors or not isinstance(actual, LoopXTurnResultKind) or actual.value != value: + raise ValueError(f'turn_result_kind: decoder does not produce registered input {value}') + rows.append(Production(site, _result_kind.__code__.co_firstlineno, 'input_witness', frozenset({actual.value}), False)) + for invalid in (None, '', 'unknown_result_kind', 3, [], {}): + errors = [] + actual = _result_kind(invalid, errors) + if actual is not None or not errors: + raise ValueError('turn_result_kind: decoder accepted an invalid input probe') + return rows diff --git a/loopx/semantics/python_production.py b/loopx/semantics/python_production.py new file mode 100644 index 0000000000..595e502249 --- /dev/null +++ b/loopx/semantics/python_production.py @@ -0,0 +1,436 @@ +"""Bounded Python production evidence; never execute inspected source. + +Known values are syntactic result possibilities, not proof of reachable traces. +Unresolved expressions retain their source locations. Owner definitions alone, +comparison operands, comments and quoted examples are not production evidence. +""" +from __future__ import annotations + +import ast +from collections import Counter +from dataclasses import dataclass +from typing import Mapping, TypeVar + +from .inventory import SourceFile + + +@dataclass(frozen=True) +class Production: + site: str + line: int + form: str + values: frozenset[str] + unresolved: bool + + +def _module(path: str) -> str: + name = path.removesuffix('.py').replace('/', '.') + return name.removesuffix('.__init__') + + +def _import_module(path: str, node: ast.ImportFrom) -> str: + if not node.level: + return node.module or '' + package = _module(path) if path.endswith('/__init__.py') else _module(path).rpartition('.')[0] + parts = package.split('.') + return '.'.join(parts[:len(parts) - node.level + 1] + ([node.module] if node.module else [])) + + +def enum_members(source: SourceFile, symbol: str, *, strict: bool = False) -> dict[str, str]: + """Extract literal members, with fail-closed generation as an explicit mode. + + Inventory/observation may inspect a bounded subset. Generation must account + for every declaration without executing source or inferring enum aliases + from iteration (which omits aliases present in ``__members__``). + """ + tree = ast.parse(source.text, filename=source.path) + classes = [n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == symbol] + if len(classes) != 1: + raise ValueError(f'{source.path}::{symbol}: expected one owner class') + result: dict[str, str] = {} + for node in classes[0].body: + target = None + value = None + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + value = node.value + elif isinstance(node, ast.AnnAssign): + target = node.target + value = node.value + if strict: + if isinstance(node, ast.Pass) or (isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)): + continue + names = [n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)] + name = target.id if isinstance(target, ast.Name) else ','.join(names) or getattr(node, 'name', symbol) + prefix = f'{source.path}:{node.lineno}: member {name}' + if (not isinstance(target, ast.Name) or target.id.startswith('__') + or (target.id.startswith('_') and target.id.endswith('_'))): + raise ValueError(f'{prefix}: unsupported owner declaration') + if target.id in result: + raise ValueError(f'{prefix}: duplicate member declaration') + if not isinstance(value, ast.Constant) or not isinstance(value.value, str): + raise ValueError(f'{prefix}: expected a literal string; computed members and aliases are unsupported') + if value.value in result.values(): + original = next(k for k, v in result.items() if v == value.value) + raise ValueError(f'{prefix}: aliases member {original}; owner and registry differ (aliases unsupported)') + if (isinstance(target, ast.Name) and isinstance(value, ast.Constant) + and isinstance(value.value, str)): + result[target.id] = value.value + return result + + +def python_literal_uses(source: SourceFile, field: str) -> set[str]: + """Literal writes and direct dispatch operands; not alias/data-flow proof.""" + tree = ast.parse(source.text, filename=source.path) + + def reads(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return node.id == field + if isinstance(node, ast.Attribute): + return node.attr == field + if isinstance(node, ast.Subscript): + return isinstance(node.slice, ast.Constant) and node.slice.value == field + if isinstance(node, ast.BoolOp): + # Preserve the common neutral fallback without attributing a + # different field selected by and/or to this action slot. + return (isinstance(node.op, ast.Or) and reads(node.values[0]) + and all(isinstance(value, ast.Constant) and value.value in ('', None) + for value in node.values[1:])) + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id == 'str' and len(node.args) == 1: + return reads(node.args[0]) + return (isinstance(node.func, ast.Attribute) and node.func.attr == 'get' + and bool(node.args) and isinstance(node.args[0], ast.Constant) + and node.args[0].value == field) + return False + + def literals(node: ast.AST) -> set[str]: + if isinstance(node, ast.Constant): + return {node.value} if isinstance(node.value, str) and node.value else set() + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + return set().union(*(literals(item) for item in node.elts)) + if isinstance(node, ast.MatchValue): + return literals(node.value) + if isinstance(node, ast.MatchOr): + return set().union(*(literals(pattern) for pattern in node.patterns)) + return set() + + # Production collection already separates conditional results from their + # conditions and does not inspect strings containing sample source text. + rows = scan_python_production(source, field=field, enums={}) + result = set().union(*(row.values for row in rows)) + for node in ast.walk(tree): + if isinstance(node, ast.Compare): + for left, operator, right in zip( + [node.left, *node.comparators[:-1]], node.ops, node.comparators, strict=True, + ): + if isinstance(operator, (ast.Eq, ast.NotEq, ast.Is, ast.IsNot, ast.In, ast.NotIn)) and reads(left): + result.update(literals(right)) + if isinstance(operator, (ast.Eq, ast.NotEq, ast.Is, ast.IsNot)) and reads(right): + result.update(literals(left)) + elif isinstance(node, ast.Match) and reads(node.subject): + for case in node.cases: + result.update(literals(case.pattern)) + return result + + +_Binding = TypeVar('_Binding') + + +def _qualified_bindings(source: SourceFile, tree: ast.Module, owners: Mapping[str, _Binding]) -> dict[str, _Binding]: + bindings = {owner.split('::')[1]: value for owner, value in owners.items() + if owner.split('::')[0] == source.path} + imports = {(_module(owner.split('::')[0]), owner.split('::')[1]): value + for owner, value in owners.items()} + for node in tree.body: + if isinstance(node, ast.ImportFrom): + for alias in node.names: + name = alias.asname or alias.name + value = imports.get((_import_module(source.path, node), alias.name)) + if value is not None: + bindings[name] = value + else: + bindings.pop(name, None) + elif isinstance(node, ast.Import): + for alias in node.names: + bindings.pop(alias.asname or alias.name.split('.')[0], None) + for node in tree.body: + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if isinstance(target, ast.Name): + bindings.pop(target.id, None) + return bindings + + +def scan_python_production( + source: SourceFile, + *, + field: str | None, + enums: Mapping[str, Mapping[str, str]], + return_functions: frozenset[str] = frozenset(), + return_paths: Mapping[str, tuple[str | int, ...]] | None = None, + call_arguments: Mapping[str, Mapping[str, int | None]] | None = None, +) -> list[Production]: + """Observe writes and owner-member results with bounded local resolution. + + ``enums`` maps module::Class to literal member values from tracked owners. + Only imported owner classes (including aliases) or the local owner qualify. + Local aliases and complete branch selections resolve only at output sites. + General reassignment and parameter shadowing become unknown. Explicit call + metadata names only reviewed builder arguments; arbitrary calls are consumers. + Nested function returns belong to that function, not a registered enclosure. + """ + tree = ast.parse(source.text, filename=source.path) + bindings = _qualified_bindings(source, tree, enums) + call_arguments = call_arguments or {} + calls = _qualified_bindings(source, tree, call_arguments) + return_paths = return_paths or {} + + result: list[Production] = [] + + def matches(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return node.id == field + if isinstance(node, ast.Attribute): + return node.attr == field + return (isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant) + and node.slice.value == field) + + def scan_scope(body: list[ast.stmt], scope: str, parameters: set[str]) -> None: + nodes: list[ast.AST] = [] + nested: list[ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef] = [] + + def collect(node: ast.AST) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + nested.append(node) + return + if isinstance(node, ast.Lambda): + return + nodes.append(node) + for child in ast.iter_child_nodes(node): + collect(child) + for statement in body: + collect(statement) + assigned = Counter(n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)) + local_owner_names = {owner.split('::')[1] for owner in enums if owner.split('::')[0] == source.path} + nested_names = {n.name for n in nested} + if scope == '': + nested_names -= local_owner_names | {owner.split('::')[1] for owner in call_arguments + if owner.split('::')[0] == source.path} + imported = set() + if scope != '': + for node in nodes: + if isinstance(node, (ast.Import, ast.ImportFrom)): + imported.update(alias.asname or alias.name.split('.')[0] for alias in node.names) + exception_targets = {n.name for n in nodes if isinstance(n, ast.ExceptHandler) and n.name} + deleted = {n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Del)} + shadows = set(assigned) | parameters | nested_names | imported | exception_targets | deleted + local_bindings = {k: v for k, v in bindings.items() if k not in shadows} + local_calls = {k: v for k, v in calls.items() if k not in shadows} + single_values = {} + for node in nodes: + if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + target = node.targets[0].id + if assigned[target] == 1 and target not in parameters: + single_values[target] = node.value + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + target = node.target.id + if assigned[target] == 1 and target not in parameters and node.value is not None: + single_values[target] = node.value + + def conditional_values(node: ast.If) -> dict[str, tuple[ast.AST, int]]: + # A complete if/elif/else defining a local in each arm is one finite + # selection. Partial branches, loops and general reassignments stay + # unknown; no assignment is itself an enum production site. + def arm(statements: list[ast.stmt]) -> dict[str, tuple[ast.AST, int]]: + if len(statements) == 1 and isinstance(statements[0], ast.If): + return conditional_values(statements[0]) + definitions = {} + for statement in statements: + if (isinstance(statement, ast.Assign) and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name)): + name = statement.targets[0].id + definitions[name] = (statement.value, definitions.get(name, (None, 0))[1] + 1) + return {name: item for name, item in definitions.items() if item[1] == 1} + left, right = arm(node.body), arm(node.orelse) + return {name: (ast.copy_location(ast.IfExp(test=node.test, body=left[name][0], + orelse=right[name][0]), node), left[name][1] + right[name][1]) + for name in left.keys() & right.keys()} + + for node in nodes: + if isinstance(node, ast.If): + for name, (value, count) in conditional_values(node).items(): + if assigned[name] == count and name not in parameters: + single_values[name] = value + + # Resolve only local containers that have not been mutated or escaped. + # A subscript write through an alias invalidates every alias, rather + # than turning a stale initializer into false scalar output evidence. + containers = {name for name, value in single_values.items() + if isinstance(value, (ast.List, ast.Dict, ast.Set))} + aliases = [(name, value.id) for name, value in single_values.items() if isinstance(value, ast.Name)] + unsafe: set[str] = set() + + def root_name(node: ast.AST) -> str | None: + while isinstance(node, (ast.Attribute, ast.Subscript)): + node = node.value + return node.id if isinstance(node, ast.Name) else None + + for node in nodes: + if isinstance(node, (ast.Attribute, ast.Subscript)) and isinstance(node.ctx, (ast.Store, ast.Del)): + if name := root_name(node): + unsafe.add(name) + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Attribute) and (name := root_name(node.func)): + unsafe.add(name) + for argument in [*node.args, *(kw.value for kw in node.keywords)]: + if isinstance(argument, ast.Name): + unsafe.add(argument.id) + for group in (containers, unsafe): + changed = True + while changed: + before = len(group) + for left, right in aliases: + if left in group or right in group: + group.update((left, right)) + changed = len(group) != before + for name in containers & unsafe: + single_values.pop(name, None) + + def bound(node: ast.AST | None, seen: frozenset[str]) -> tuple[ast.AST | None, frozenset[str]]: + while isinstance(node, ast.Name) and node.id in single_values and node.id not in seen: + definition = single_values[node.id] + if (definition.lineno, definition.col_offset) >= (node.lineno, node.col_offset): + break + seen = seen | {node.id} + node = definition + return node, seen + + def index_value(node: ast.AST) -> str | int | None: + if isinstance(node, ast.Constant) and type(node.value) in (str, int): + return node.value + if (isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) + and isinstance(node.operand, ast.Constant) and type(node.operand.value) is int): + return -node.operand.value + return None + + def lookup(container: ast.AST | None, key: str | int | None) -> tuple[list[ast.AST], bool]: + if isinstance(container, (ast.Tuple, ast.List)): + if type(key) is int: + return ([container.elts[key]], False) if -len(container.elts) <= key < len(container.elts) else ([], True) + if key is not None: + return [], True + return list(container.elts), True + if isinstance(container, ast.Dict): + keys = [index_value(k) if k is not None else None for k in container.keys] + if key is not None and all(k is not None for k in keys): + # Python dict construction keeps the last duplicate key. + found = [v for k, v in zip(keys, container.values, strict=True) if k == key] + return ([found[-1]], False) if found else ([], True) + return list(container.values), True + return [], True + + def resolve(node: ast.AST | None, seen: frozenset[str] = frozenset(), *, enum_only: bool = False) -> tuple[set[str], bool]: + node, seen = bound(node, seen) + if isinstance(node, ast.Constant): + if isinstance(node.value, str): + return ({node.value} if node.value and not enum_only else set()), False + return set(), node.value is not None + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice) or (isinstance(node.slice, ast.Constant) + and type(node.slice.value) not in (str, int)): + return set(), True + container, visited = bound(node.value, seen) + choices, unknown = lookup(container, index_value(node.slice)) + known: set[str] = set() + for value in choices: + part, unresolved = resolve(value, visited, enum_only=enum_only) + known.update(part) + unknown |= unresolved + return known, unknown + if isinstance(node, (ast.IfExp, ast.BoolOp)): + operands = [node.body, node.orelse] if isinstance(node, ast.IfExp) else node.values + parts = [resolve(value, seen, enum_only=enum_only) for value in operands] + return set().union(*(values for values, _ in parts)), any(unknown for _, unknown in parts) + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == 'str' and node.func.id not in shadows + and len(node.args) == 1 and not node.keywords): + return resolve(node.args[0], seen, enum_only=enum_only) + if isinstance(node, ast.Attribute): + member = node.value if node.attr == 'value' else node + if isinstance(member, ast.Attribute) and isinstance(member.value, ast.Name): + members = local_bindings.get(member.value.id) + if members is not None: + if member.attr not in members: + raise ValueError(f'{source.path}:{node.lineno}: unknown owner member {member.attr}') + return {members[member.attr]}, False + if node.attr == 'value' and isinstance(node.value, ast.Name): + return enum_object_value(node.value, seen) + return set(), True + + def enum_object_value(node: ast.AST, seen: frozenset[str]) -> tuple[set[str], bool]: + node, seen = bound(node, seen) + if isinstance(node, ast.IfExp): + parts = [enum_object_value(value, seen) for value in (node.body, node.orelse)] + return set().union(*(v for v, _ in parts)), any(u for _, u in parts) + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in local_bindings: + return resolve(node, seen, enum_only=True) + # A serialized string (including Action.RUN.value) is not an enum + # object with another .value attribute. + return set(), True + + def returned(node: ast.AST | None, path: tuple[str | int, ...], seen: frozenset[str] = frozenset()) -> tuple[set[str], bool]: + if not path: + return resolve(node, seen) + node, seen = bound(node, seen) + choices, unknown = lookup(node, path[0]) + parts = [returned(value, path[1:], seen) for value in choices] + return set().union(*(v for v, _ in parts)), unknown or any(u for _, u in parts) + + def record(node: ast.AST | None, form: str, location: ast.AST) -> None: + values, unknown = (returned(node, return_paths.get(scope, ())) if form == 'return' else resolve(node)) + if form == 'keyword_unproved': + unknown = True # Argument name alone does not prove an output role. + result.append(Production(f'{source.path}::{scope}', location.lineno, form, frozenset(values), unknown)) + + for node in nodes: + if isinstance(node, ast.Assign): + if field and any(matches(t) for t in node.targets): + record(node.value, 'assignment', node) + elif isinstance(node, ast.AnnAssign) and field and matches(node.target): + record(node.value, 'assignment', node) + elif isinstance(node, ast.Dict) and field: + for key, value in zip(node.keys, node.values, strict=True): + if isinstance(key, ast.Constant) and key.value == field: + record(value, 'dict', node) + elif isinstance(node, ast.Call): + output_arguments = local_calls.get(node.func.id, {}) if isinstance(node.func, ast.Name) else {} + for kw in node.keywords: + if kw.arg in output_arguments: + record(kw.value, 'call_argument', node) + elif field and kw.arg == field: + record(kw.value, 'keyword_unproved', node) + for position in output_arguments.values(): + if position is not None and position < len(node.args): + record(node.args[position], 'call_argument', node) + elif isinstance(node, ast.Return) and node.value is not None: + if scope in return_functions: + record(node.value, 'return', node) + else: + values, unknown = resolve(node.value, enum_only=True) + if values: + result.append(Production(f'{source.path}::{scope}', node.lineno, 'enum_result', frozenset(values), unknown)) + for child in nested: + name = child.name if scope == '' else f'{scope}.{child.name}' + params: set[str] = set() + if not isinstance(child, ast.ClassDef): + args = child.args + params = {a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)} + params.update(a.arg for a in (args.vararg, args.kwarg) if a) + # A nested closure might shadow an owner in any enclosing scope. + scan_scope(child.body, name, params | shadows) + + scan_scope(tree.body, '', set()) + return sorted(set(result), key=lambda row: (row.site, row.line, row.form, sorted(row.values))) diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 17558ccd1d..38f5193974 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -28,13 +28,26 @@ "pass_through" ], "role_hierarchy": { - "consumer": ["interpreter", "pass_through"] + "consumer": [ + "interpreter", + "pass_through" + ] }, "enforcement_policy": { - "blocking_now": ["F5_projection_totality"], - "blocking_next": ["F1_producer_closedness", "F2_canonical_value_liveness", "F4_scope_separation"], - "advisory": ["F3_consumer_domain_closedness"], - "unproved": ["F6_persistence_version_compatibility"] + "blocking_now": [ + "F5_projection_totality" + ], + "blocking_next": [ + "F1_producer_closedness", + "F2_canonical_value_liveness", + "F4_scope_separation" + ], + "advisory": [ + "F3_consumer_domain_closedness" + ], + "unproved": [ + "F6_persistence_version_compatibility" + ] }, "relations": { "defines": "D ⊆ S × V: a site defines a vocabulary carrier", @@ -105,7 +118,7 @@ }, "coverage_floor": { "vocabularies": 26, - "owner_symbols": 46, + "owner_symbols": 49, "literal_scan_fields": 1, "projections": 1, "relations": 9, @@ -143,6 +156,30 @@ "validation_failed": "Legacy failure class per turn-loop-controller-v0; always routes to repair.", "writeback_failed": "Legacy failure class per turn-loop-controller-v0; always routes to repair.", "quota_spend_failed": "Legacy failure class per turn-loop-controller-v0; always routes to repair." + }, + "input_producer": "loopx/control_plane/turn_driver/transaction.py::_result_kind", + "producers": [ + "loopx/control_plane/turn_driver/executor.py::_host_result_stage", + "loopx/control_plane/turn_driver/executor.py::_run_task_validator", + "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt", + "loopx/control_plane/turn_driver/executor.py::_task_validation_stage", + "loopx/control_plane/turn_driver/transaction.py::_result_kind" + ], + "call_producers": { + "loopx/control_plane/turn_driver/executor.py::_host_failure": [ + "kind" + ], + "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt": [ + "recovery_kind" + ] + }, + "return_producers": [ + "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt" + ], + "return_paths": { + "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt": [ + "recovery_kind" + ] } }, "turn_route": { @@ -162,7 +199,23 @@ "wait", "blocked", "contract_error" - ] + ], + "producers": [ + "loopx/control_plane/turn_driver/driver.py::_typed_route", + "loopx/control_plane/turn_driver/driver.py::build_loopx_turn_plan", + "loopx/control_plane/turn_driver/loop_controller.py::_envelope_route" + ], + "return_producers": [ + "loopx/control_plane/turn_driver/driver.py::_typed_route", + "loopx/control_plane/turn_driver/loop_controller.py::_envelope_route", + "loopx/control_plane/turn_driver/driver.py::build_loopx_turn_plan" + ], + "return_paths": { + "loopx/control_plane/turn_driver/driver.py::build_loopx_turn_plan": [ + "route", + "kind" + ] + } }, "loop_disposition": { "meaning": "Pure controller verdict for the outer loop after combining the last Turn receipt with the fresh route.", @@ -181,27 +234,56 @@ "repair", "replan", "terminal" - ] + ], + "producers": [ + "loopx/control_plane/turn_driver/loop_controller.py::_completion_disposition", + "loopx/control_plane/turn_driver/loop_controller.py::_replan_disposition", + "loopx/control_plane/turn_driver/loop_controller.py::_route_to_disposition", + "loopx/control_plane/turn_driver/loop_controller.py::decide_loop_disposition" + ], + "return_producers": [ + "loopx/control_plane/turn_driver/loop_controller.py::_route_to_disposition" + ], + "call_producers": { + "loopx/control_plane/turn_driver/loop_controller.py::_disposition": [ + "disposition" + ] + } }, "agent_scope_frontier_action": { - "meaning": "Frontier verdict for an agent-scoped lane; its value is written into the agent_scope_frontier.effective_action slot of the Turn Envelope.", + "meaning": "Frontier verdict for an agent-scoped lane. New v1 payloads use agent_scope_frontier.action; the root should-run effective_action projects the same value through its registered disjoint union. Legacy v0 signed payloads remain readable without rewriting.", "tier": "kernel", "status": "canonical", "owners": { "python": "loopx/control_plane/agents/agent_scope_frontier.py::AgentScopeFrontierAction", - "typescript": null + "typescript": "loopx/control_plane/agents/agent_scope_frontier.generated.ts::AGENT_SCOPE_FRONTIER_ACTIONS" }, "values": [ "agent_scope_exhausted", "agent_scope_wait", "reassignment_required", "successor_replan_required" - ] + ], + "producers": [ + "loopx/control_plane/agents/agent_scope.py::_blocked_successor_wait_frontier", + "loopx/control_plane/agents/agent_scope.py::_blocking_handoff_frontier", + "loopx/control_plane/agents/agent_scope.py::_cleared_handoff_frontier", + "loopx/control_plane/agents/agent_scope.py::_deferred_resume_frontier", + "loopx/control_plane/agents/agent_scope.py::_monitor_blocked_resume_frontier", + "loopx/control_plane/agents/agent_scope.py::_other_agent_or_exhausted_frontier", + "loopx/control_plane/agents/agent_scope.py::_route_continuation_frontier", + "loopx/control_plane/agents/agent_scope.py::_selected_candidate_priority_frontier" + ], + "call_producers": { + "loopx/control_plane/agents/agent_scope_frontier.py::build_agent_scope_frontier_payload": [ + "action" + ] + } }, "lease_action": { "meaning": "Authority-core lease mutation verb.", "tier": "kernel", - "status": "canonical", + "status": "legacy", "owners": { "python": "loopx/control_plane/coordination/authority_core.py::LeaseAction", "typescript": null @@ -211,15 +293,40 @@ "renew", "transfer", "release" - ] + ], + "producers": [], + "compatibility_only": { + "acquire": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + }, + "renew": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + }, + "transfer": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + }, + "release": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + } + }, + "value_notes": { + "acquire": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review.", + "renew": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review.", + "transfer": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review.", + "release": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review." + } }, "effective_action": { - "meaning": "Compacted should-run verdict carried by status/should-run payloads and the Turn Envelope; consumers dispatch on it by string comparison. No enum exists on either runtime, so the owner is null and the literal scan is the only check.", + "meaning": "Compacted should-run verdict carried by status/should-run payloads and the Turn Envelope; the Python enum owns the finite value domain while the wire field remains a string for compatibility.", "tier": "kernel", "status": "merge_candidate", "owners": { - "python": null, - "typescript": null + "python": "loopx/control_plane/quota/effective_action.py::EffectiveAction", + "typescript": "loopx/control_plane/quota/effective_action.generated.ts::EFFECTIVE_ACTIONS" }, "literal_scan": { "field": "effective_action", @@ -236,11 +343,13 @@ "agent_workspace_repair", "automation_prompt_upgrade_required", "autonomous_replan_required", - "block_replay", + "blocked_health", + "blocked_wait", "boundary_projection_repair", "capability_bridge_repair", "control_plane_health_repair", "control_plane_projection_repair", + "control_plane_repair", "coordinate_task_bundle", "external_evidence_observe", "governed_capability_intent", @@ -250,32 +359,55 @@ "monitor_due", "monitor_quiet_skip", "normal_run", - "observe_replay", - "operator_gate", + "operator_gate_notify", "operator_inbox_material_review_due", "outcome_floor_recovery", "peer_coordination_blocked", - "quota_action_selection_deferred", - "quota_action_selection_rejected", "quota_skip", "runtime_user_gate_projection_repair", "scoped_user_gate_fallback", - "skip", "state_projection_gap_repair", "terminal_no_followup", + "throttled_skip", "todo_decision_scope_projection_repair", "unsettled_host_turn_recovery" ], - "variable_sourced_values": { - "quota_action_selection_deferred": "loopx/cli_commands/quota.py", - "quota_action_selection_rejected": "loopx/cli_commands/quota.py" - }, + "producers": [ + "loopx/cli_commands/quota.py::_apply_requested_quota_action_selection_preflight", + "loopx/control_plane/quota/decision_summary.py::_task_orchestration_effective_action", + "loopx/control_plane/quota/decision_summary.py::quota_effective_action", + "loopx/control_plane/quota/decision_summary.py::resolve_quota_run_decision", + "loopx/control_plane/quota/heartbeat_receipt.py::fail_heartbeat_receipt", + "loopx/control_plane/quota/live_decision.py::_apply_pending_capability_intent_precedence", + "loopx/control_plane/quota/projection_repair.py::build_boundary_projection_repair_hint", + "loopx/control_plane/quota/projection_repair.py::build_state_projection_gap_repair_hint", + "loopx/control_plane/quota/settlement_precedence.py::apply_settled_replay_payload_precedence", + "loopx/control_plane/quota/settlement_precedence.py::apply_settled_replay_route_precedence", + "loopx/control_plane/quota/should_run.py::build_quota_paused_should_run_payload", + "loopx/control_plane/quota/should_run_packet.py::_apply_agent_monitor_only_precedence", + "loopx/control_plane/quota/should_run_packet.py::_resolve_quota_should_run_route", + "loopx/control_plane/quota/stall_repair.py::build_quota_stall_self_repair_hint", + "loopx/control_plane/quota/stall_repair.py::build_runtime_capability_user_gate_repair_hint", + "loopx/control_plane/quota/unsettled_host_turn.py::apply_unsettled_host_turn_recovery_if_required", + "loopx/control_plane/todos/decision_scope.py::build_required_decision_scope_repair_hint", + "loopx/control_plane/todos/user_gate.py::apply_scoped_user_gate_fallback_projection" + ], + "compatibility_only": {}, "value_notes": { - "skip": "Compared in loopx/control_plane/todos/user_gate.py; no producer writes it into effective_action. Candidate dead value (decision-level skip lives in the decision field).", - "observe_replay": "Written only by turn_journal.ts into the replay observation slot; not a should-run verdict. Slot split is RFC Section 12 Q6.", - "block_replay": "Written only by turn_journal.ts into the replay observation slot; not a should-run verdict. Slot split is RFC Section 12 Q6.", - "quota_action_selection_deferred": "Reaches the slot through the error_code variable in cli_commands/quota.py; an error code doubling as a verdict.", - "quota_action_selection_rejected": "Reaches the slot through the error_code variable in cli_commands/quota.py; an error code doubling as a verdict." + "blocked_health": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "blocked_wait": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "control_plane_repair": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "operator_gate_notify": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "throttled_skip": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions." + }, + "return_producers": [ + "loopx/control_plane/quota/decision_summary.py::quota_effective_action", + "loopx/control_plane/quota/decision_summary.py::_task_orchestration_effective_action" + ], + "return_paths": { + "loopx/control_plane/quota/decision_summary.py::_task_orchestration_effective_action": [ + 0 + ] } }, "settlement_step_kind": { @@ -637,22 +769,14 @@ "field": "effective_action", "slots": [ { - "slot": "decision.effective_action", - "vocabulary": "effective_action" - }, - { - "slot": "agent_scope_frontier.effective_action", - "vocabulary": "agent_scope_frontier_action" - }, - { - "slot": "turn_journal_replay.observation.effective_action", - "values": [ - "observe_replay", - "block_replay" + "slot": "should_run.effective_action", + "vocabularies": [ + "effective_action", + "agent_scope_frontier_action" ] } ], - "note": "One field name, three vocabularies, inside one Turn Envelope. The literal scan cannot tell slots apart, so the decision vocabulary lists the replay values with notes until Q6 splits them." + "note": "The root should-run/Turn Envelope action is an explicitly registered disjoint union of decision and frontier actions. Nested frontier v1 uses action only; legacy v0 fields remain readable without rewriting signed snapshots. Replay uses observation.decision, and selection failures use quota_skip plus error_code." } ], "subsets": [ @@ -713,27 +837,27 @@ "meaning": "Decision fields the should-run documentation already calls legacy. Budgets count modules under loopx/ that still mention the field.", "fields": { "execution_obligation": { - "python_module_budget": 21, + "python_module_budget": 20, "typescript_module_budget": 1 }, "heartbeat_recommendation": { - "python_module_budget": 18, + "python_module_budget": 17, "typescript_module_budget": 1 }, "work_lane_contract": { - "python_module_budget": 32, + "python_module_budget": 29, "typescript_module_budget": 3 }, "external_evidence_observation": { - "python_module_budget": 11, + "python_module_budget": 8, "typescript_module_budget": 1 }, "goal_boundary": { - "python_module_budget": 35, + "python_module_budget": 30, "typescript_module_budget": 2 }, "protocol_action_packet": { - "python_module_budget": 7, + "python_module_budget": 5, "typescript_module_budget": 2 } } @@ -756,8 +880,32 @@ "multi_value_fork_definitions": 10, "same_runtime_forks_semantic": 18, "conflicting_values_semantic": 2, - "multi_value_meaning": "Enums, named closed sets, Literal aliases, and TypeScript as-const arrays are vocabulary exactly as a NAME = \"value\" constant is, so they get the same collision rule. One name defined in two modules with identical values is a twin; with different values it is a fork.", + "multi_value_meaning": "Enums, named closed sets, Literal aliases, and TypeScript as-const arrays are vocabulary exactly as a NAME = \"value\" constant is, so they get the same collision rule. One name defined in two modules with identical values is a twin; with different values it is a fork. The semantic multi-value-fork budget excludes only names declared in scope_declarations.", "multi_value_forks_note": "The 4 counted forks include SOURCE_SURFACES, whose four definitions are four CLI commands each listing its own data sources; that is bounded-context reuse of one name, not drift. It stays in the budget until M0.5 adds a scope field (RFC Section 5) and must not be removed by renaming.", - "semantic_meaning": "same_runtime_forks_semantic and conflicting_values_semantic exclude module-local convention names such as SCHEMA_VERSION, COMMAND, or *_LABEL, which every module legitimately names for itself. The remaining names are shared vocabulary, where a duplicate is real drift rather than local naming; the unfiltered totals stay visible in the generated inventory summary." + "semantic_meaning": "same_runtime_forks_semantic and conflicting_values_semantic exclude module-local convention names such as SCHEMA_VERSION, COMMAND, or *_LABEL, which every module legitimately names for itself. The remaining names are shared vocabulary, where a duplicate is real drift rather than local naming; the unfiltered totals stay visible in the generated inventory summary.", + "multi_value_forks_semantic": 3 + }, + "scope_declarations": { + "SOURCE_SURFACES": { + "kind": "bounded_context", + "contexts": [ + { + "id": "global_risks", + "owner": "loopx/global_risks.py::SOURCE_SURFACES" + }, + { + "id": "global_todos", + "owner": "loopx/global_todos.py::SOURCE_SURFACES" + }, + { + "id": "summary_all", + "owner": "loopx/summary_all.py::SOURCE_SURFACES" + }, + { + "id": "pr_review", + "owner": "loopx/pr_review.py::SOURCE_SURFACES" + } + ] + } } } diff --git a/scripts/generate_semantic_bindings.py b/scripts/generate_semantic_bindings.py new file mode 100644 index 0000000000..c456a71985 --- /dev/null +++ b/scripts/generate_semantic_bindings.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Generate the quota/frontier TypeScript bindings and vocabulary glossary. + +Run with ``uv run python scripts/generate_semantic_bindings.py [--check]``. +The Python enum remains the value owner during M1. Product code does not import +this generator or the build-time semantic registry. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from loopx.semantics.inventory import load_sources # noqa: E402 +from loopx.semantics.python_production import enum_members # noqa: E402 +from loopx.semantics.production import quota_action_domain # noqa: E402 + +REGISTRY = ROOT / 'loopx/semantics/vocabulary_v0.json' +BINDING = ROOT / 'loopx/control_plane/quota/effective_action.generated.ts' +FRONTIER_BINDING = ROOT / 'loopx/control_plane/agents/agent_scope_frontier.generated.ts' +GLOSSARY = ROOT / 'docs/reference/glossary.md' + + +def render_binding(members: dict[str, str], source_owner: str, symbol: str, array: str) -> str: + lines = [ + '// Generated by scripts/generate_semantic_bindings.py; do not edit.', + f'// Value owner: {source_owner}', + '', + f'export const {array} = [', + *(f' {json.dumps(value)},' for value in members.values()), + '] as const;', + f'export type {symbol}Value = (typeof {array})[number];', + '', + f'export const {symbol} = {{', + *(f' {name}: {array}[{index}],' for index, name in enumerate(members)), + '} as const;', + '', + ] + return '\n'.join(lines) + + +def render_glossary(registry: dict) -> str: + lines = [ + '# Semantic Vocabulary Glossary / 语义词表', + '', + '', + '', + 'This is a generated view of the curated registry, not another authority.', + '这是注册表的生成视图;修改定义应编辑 owner 和注册表,然后重新生成。', + '', + 'Equal spellings or value sets do not prove equal meaning. Producers write', + 'values; consumers interpret or pass them through; only owners define sets.', + '同名或相同值集不等于同一语义。生产者写值,消费者解释或透传,owner 定义集合。', + '', + 'See the [RFC](../architecture/rfcs/semantic-vocabulary-convergence-v0.md)', + 'and its [中文版本](../architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md)', + 'for scope, compatibility, proof boundaries and migration gates.', + '', + ] + for name, vocabulary in sorted(registry['vocabularies'].items()): + lines += [f'## {name}', '', vocabulary['meaning'], '', + f"- Tier / 层级: `{vocabulary['tier']}`; status / 状态: `{vocabulary['status']}`."] + for runtime, owner in vocabulary['owners'].items(): + if owner: + module, symbol = owner.split('::') + lines.append(f'- {runtime}: [`{symbol}`](../../{module}).') + lines.append('- Values / 值: ' + ', '.join(f'`{value}`' for value in vocabulary['values']) + '.') + if compatibility := vocabulary.get('compatibility_only'): + lines.append('- Compatibility only / 兼容保留: ' + ', '.join(f'`{value}`' for value in sorted(compatibility)) + '.') + lines.append('') + return '\n'.join(lines) + + +def build_artifacts() -> dict[Path, str]: + registry = json.loads(REGISTRY.read_text(encoding='utf-8')) + sources = {source.path: source for source in load_sources(ROOT)} + artifacts = {} + for name, path, array in ( + ('effective_action', BINDING, 'EFFECTIVE_ACTIONS'), + ('agent_scope_frontier_action', FRONTIER_BINDING, 'AGENT_SCOPE_FRONTIER_ACTIONS'), + ): + vocabulary = registry['vocabularies'][name] + owner = vocabulary['owners']['python'] + module, symbol = owner.split('::') + if module not in sources: + raise ValueError(f'{name} owner must be a tracked source') + members = enum_members(sources[module], symbol, strict=True) + values = list(members.values()) + if (len(values) != len(set(values)) or set(values) != set(vocabulary['values'])): + raise ValueError(f'{name} owner and registry differ; reconcile them before generation') + artifacts[path] = render_binding(members, owner, symbol, array) + quota_action_domain(registry) + artifacts[BINDING] += ( + '\nimport type { AgentScopeFrontierActionValue } from "../agents/agent_scope_frontier.generated.ts";\n' + 'export type QuotaEffectiveActionValue = EffectiveActionValue | AgentScopeFrontierActionValue;\n' + ) + artifacts[GLOSSARY] = render_glossary(registry) + return artifacts + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--check', action='store_true', help='fail without writing if either artifact is stale') + args = parser.parse_args() + stale = [] + for path, expected in build_artifacts().items(): + if path.is_file() and path.read_text(encoding='utf-8') == expected: + continue + stale.append(path.relative_to(ROOT).as_posix()) + if not args.check: + path.write_text(expected, encoding='utf-8') + if stale and args.check: + print('Stale semantic artifacts: ' + ', '.join(stale) + + '; run uv run python scripts/generate_semantic_bindings.py', file=sys.stderr) + return 1 + print('semantic bindings/glossary: ' + ('generated' if stale else 'up to date')) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/generate_semantic_inventory.py b/scripts/generate_semantic_inventory.py index ca254b2e24..8672eeae75 100755 --- a/scripts/generate_semantic_inventory.py +++ b/scripts/generate_semantic_inventory.py @@ -2,9 +2,9 @@ """Generate or check the repository-wide semantic inventory. Usage: - python3.11 scripts/generate_semantic_inventory.py # rewrite inventory_v0.json - python3.11 scripts/generate_semantic_inventory.py --check # exit 1 when the file is stale - python3.11 scripts/generate_semantic_inventory.py --report # print advisory consumer ranking + uv run python scripts/generate_semantic_inventory.py # rewrite inventory_v0.json + uv run python scripts/generate_semantic_inventory.py --check # exit 1 when the file is stale + uv run python scripts/generate_semantic_inventory.py --report # print advisory consumer ranking """ from __future__ import annotations diff --git a/scripts/semantic_production_scan.mjs b/scripts/semantic_production_scan.mjs new file mode 100644 index 0000000000..d1a6b0efec --- /dev/null +++ b/scripts/semantic_production_scan.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Parse supplied tracked source text only; never load or execute product modules. +import ts from 'typescript'; + +let input = ''; +for await (const chunk of process.stdin) input += chunk; +const request = JSON.parse(input); +const result = []; +for (const source of request.sources) { + const tree = ts.createSourceFile(source.path, source.text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + if (tree.parseDiagnostics.length) { + const line = tree.getLineAndCharacterOfPosition(tree.parseDiagnostics[0].start ?? 0).line + 1; + process.stdout.write(JSON.stringify({error: {path: source.path, line, code: "typescript_syntax"}})); + process.exit(2); + } + const field = request.field; + const returns = new Set((request.return_functions ?? []).filter(x => x.startsWith(`${source.path}::`))); + const unwrap = node => { + while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node))) node = node.expression; + return node; + }; + const values = expression => { + const node = unwrap(expression); + if (!node) return {values: [], unresolved: true}; + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return {values: node.text ? [node.text] : [], unresolved: false}; + if (node.kind === ts.SyntaxKind.NullKeyword) return {values: [], unresolved: false}; + if (ts.isConditionalExpression(node)) { + const left = values(node.whenTrue), right = values(node.whenFalse); + return {values: [...new Set([...left.values, ...right.values])].sort(), unresolved: left.unresolved || right.unresolved}; + } + return {values: [], unresolved: true}; + }; + const staticName = expression => { + const node = unwrap(expression); + return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : null; + }; + const named = node => { + if (!node) return null; + if (ts.isComputedPropertyName(node)) return staticName(node.expression); + return ts.isIdentifier(node) ? node.text : staticName(node); + }; + const target = node => ts.isIdentifier(node) ? node.text === field + : ts.isPropertyAccessExpression(node) ? node.name.text === field + : ts.isElementAccessExpression(node) && staticName(node.argumentExpression) === field; + const reads = expression => { + const node = unwrap(expression); + if (!node) return false; + if (target(node)) return true; + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && + node.expression.text === 'String' && node.arguments.length === 1) return reads(node.arguments[0]); + return ts.isBinaryExpression(node) && + [ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(node.operatorToken.kind) && + reads(node.left) && (staticName(node.right) === "" || unwrap(node.right).kind === ts.SyntaxKind.NullKeyword); + }; + const literals = expression => { + const node = unwrap(expression); + if (!node) return []; + if (ts.isArrayLiteralExpression(node)) return node.elements.flatMap(literals); + if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'Set') { + return (node.arguments ?? []).flatMap(literals); + } + return values(node).values; + }; + function walk(node, scope) { + if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) scope = scope === '' ? named(node.name) : `${scope}.${named(node.name)}`; + else if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + const parent = node.parent; + const name = ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) ? named(parent.name) : null; + scope = name ? (scope === '' ? name : `${scope}.${name}`) : ''; + } + let expression, form; + if (ts.isPropertyAssignment(node) && named(node.name) === field) { expression = node.initializer; form = 'object'; } + else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && target(node.left)) { expression = node.right; form = 'assignment'; } + else if (ts.isVariableDeclaration(node) && named(node.name) === field && node.initializer) { expression = node.initializer; form = 'assignment'; } + else if (ts.isReturnStatement(node) && returns.has(`${source.path}::${scope}`)) { expression = node.expression; form = 'return'; } + if (form) { + result.push({site: `${source.path}::${scope}`, line: tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1, form, ...values(expression)}); + } + if (request.mode === 'literal_uses') { + let observed = []; + if (ts.isBinaryExpression(node) && [ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(node.operatorToken.kind)) { + if (reads(node.left)) observed.push(...literals(node.right)); + if (reads(node.right)) observed.push(...literals(node.left)); + } else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && + ['includes', 'has'].includes(node.expression.name.text) && node.arguments.some(reads)) { + observed.push(...literals(node.expression.expression)); + } else if (ts.isSwitchStatement(node) && reads(node.expression)) { + for (const clause of node.caseBlock.clauses) if (ts.isCaseClause(clause)) observed.push(...literals(clause.expression)); + } + if (observed.length) result.push({site: `${source.path}::${scope}`, + line: tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1, + form: 'dispatch', values: observed, unresolved: false}); + } + ts.forEachChild(node, child => walk(child, scope)); + } + walk(tree, ''); +} +process.stdout.write(JSON.stringify(result)); diff --git a/tests/architecture/test_semantic_bindings.py b/tests/architecture/test_semantic_bindings.py new file mode 100644 index 0000000000..f0d1d9138b --- /dev/null +++ b/tests/architecture/test_semantic_bindings.py @@ -0,0 +1,152 @@ +"""Generated bindings must preserve the owner domain and fail closed on drift.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from loopx.control_plane.quota.effective_action import EffectiveAction +from loopx.control_plane.agents.agent_scope_frontier import AgentScopeFrontierAction +from loopx.semantics.inventory import SourceFile +from scripts import generate_semantic_bindings as generator + + +def test_checked_in_semantic_artifacts_are_fresh() -> None: + result = subprocess.run( + [sys.executable, str(Path(generator.__file__)), "--check"], + cwd=generator.ROOT, capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("path, enum, symbol, array", [ + (generator.BINDING, EffectiveAction, "EffectiveAction", "EFFECTIVE_ACTIONS"), + (generator.FRONTIER_BINDING, AgentScopeFrontierAction, "AgentScopeFrontierAction", "AGENT_SCOPE_FRONTIER_ACTIONS"), +]) +def test_typescript_binding_preserves_python_member_names_and_values(path, enum, symbol, array) -> None: + result = subprocess.run( + ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", + f"import {{{symbol}, {array}}} from {json.dumps(path.as_uri())};" + f"console.log(JSON.stringify({{members: {symbol}, values: {array}}}));"], + cwd=generator.ROOT, capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stderr + binding = json.loads(result.stdout) + assert binding["members"] == {name: member.value for name, member in enum.__members__.items()} + assert binding["values"] == [member.value for member in enum] + + +@pytest.mark.parametrize("initial", [None, "stale artifact\n"]) +def test_check_does_not_repair_stale_or_missing_artifacts(tmp_path, monkeypatch, capsys, initial): + artifact = tmp_path / "binding.ts" + if initial is not None: + artifact.write_text(initial, encoding="utf-8") + monkeypatch.setattr(generator, "ROOT", tmp_path) + monkeypatch.setattr(generator, "build_artifacts", lambda: {artifact: "current artifact\n"}) + monkeypatch.setattr(sys, "argv", ["generate_semantic_bindings.py", "--check"]) + assert generator.main() == 1 + assert "uv run python scripts/generate_semantic_bindings.py" in capsys.readouterr().err + assert (artifact.read_text(encoding="utf-8") if artifact.exists() else None) == initial + + monkeypatch.setattr(sys, "argv", ["generate_semantic_bindings.py"]) + assert generator.main() == 0 + written_at = artifact.stat().st_mtime_ns + assert artifact.read_text(encoding="utf-8") == "current artifact\n" + assert generator.main() == 0 + assert artifact.stat().st_mtime_ns == written_at + monkeypatch.setattr(sys, "argv", ["generate_semantic_bindings.py", "--check"]) + assert generator.main() == 0 + + +@pytest.mark.parametrize("declarations", [ + ' NORMAL = "normal"\n UNKNOWN = "unregistered"\n', + ' NORMAL = "normal"\n ALIAS = "normal"\n', +]) +def test_generator_rejects_owner_mismatch_and_aliases(tmp_path, monkeypatch, declarations): + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"vocabularies": {"effective_action": { + "owners": {"python": "loopx/owner.py::Action"}, "values": ["normal"], + }}}), encoding="utf-8") + monkeypatch.setattr(generator, "REGISTRY", registry) + source = SourceFile("loopx/owner.py", ".py", 'from enum import Enum\nclass Action(str, Enum):\n' + declarations) + monkeypatch.setattr(generator, "load_sources", lambda root: [source]) + with pytest.raises(ValueError, match="owner and registry differ"): + generator.build_artifacts() + + +@pytest.mark.parametrize('declaration', [ + ' NEW = "review_" + "only"\n', + ' ALIAS = NORMAL_RUN\n', + ' NORMAL_RUN = "normal_run"\n', +]) +def test_generator_rejects_unsupported_or_duplicate_owner_members(monkeypatch, declaration): + sources = generator.load_sources(generator.ROOT) + owner = 'loopx/control_plane/quota/effective_action.py' + changed = [SourceFile(s.path, s.suffix, s.text + declaration) if s.path == owner else s for s in sources] + monkeypatch.setattr(generator, 'load_sources', lambda root: changed) + monkeypatch.setattr(sys, 'argv', ['generate_semantic_bindings.py', '--check']) + with pytest.raises(ValueError, match=r'effective_action.py:\d+:.*member'): + generator.main() + + +def test_generator_does_not_silently_drop_annotated_members(monkeypatch): + sources = generator.load_sources(generator.ROOT) + owner = 'loopx/control_plane/quota/effective_action.py' + changed = [SourceFile(s.path, s.suffix, s.text + ' NEW: str = "review_only"\n') if s.path == owner else s for s in sources] + monkeypatch.setattr(generator, 'load_sources', lambda root: changed) + with pytest.raises(ValueError, match='owner and registry differ'): + generator.build_artifacts() + + +@pytest.mark.parametrize('declarations,member', [ + (' NEW = factory()\n', 'NEW'), + (' NEW = 1\n', 'NEW'), + (' NEW: str\n', 'NEW'), + (' NEW: str = "computed" + "value"\n', 'NEW'), + (' A = B = "new"\n', 'A,B'), + (' A, B = "new", "value"\n', 'A,B'), + (' NORMAL = "run"\n ALIAS = "run"\n', 'ALIAS'), + (' NORMAL = "run"\n NORMAL: str = "wait"\n', 'NORMAL'), + (' _ignore_ = "NORMAL"\n NORMAL = "run"\n', '_ignore_'), +]) +def test_strict_owner_errors_name_the_path_line_and_member(declarations, member): + source = SourceFile('loopx/owner.py', '.py', 'class Action(str, Enum):\n' + declarations) + with pytest.raises(ValueError) as error: + generator.enum_members(source, 'Action', strict=True) + assert str(error.value).startswith('loopx/owner.py:') + assert f'member {member}' in str(error.value) + + +def test_annotated_literal_binding_includes_every_declared_member(monkeypatch): + sources = generator.load_sources(generator.ROOT) + owner = 'loopx/control_plane/quota/effective_action.py' + changed = [SourceFile(s.path, s.suffix, s.text.replace('NORMAL_RUN =', 'NORMAL_RUN: str =')) + if s.path == owner else s for s in sources] + monkeypatch.setattr(generator, 'load_sources', lambda root: changed) + assert generator.build_artifacts()[generator.BINDING] == generator.BINDING.read_text() + + +def test_failed_owner_validation_never_writes_any_artifact(tmp_path, monkeypatch): + sources = generator.load_sources(generator.ROOT) + owner = 'loopx/control_plane/agents/agent_scope_frontier.py' + changed = [SourceFile(s.path, s.suffix, s.text.replace( + 'AGENT_SCOPE_WAIT = "agent_scope_wait"', 'AGENT_SCOPE_WAIT = invoke_source()')) + if s.path == owner else s for s in sources] + monkeypatch.setattr(generator, 'load_sources', lambda root: changed) + artifact = tmp_path / 'binding.ts' + artifact.write_text('preserve me') + monkeypatch.setattr(generator, 'BINDING', artifact) + monkeypatch.setattr(sys, 'argv', ['generate_semantic_bindings.py']) + with pytest.raises(ValueError, match='member AGENT_SCOPE_WAIT'): + generator.main() + assert artifact.read_text() == 'preserve me' + + +def test_strict_extraction_does_not_execute_inspected_source(): + source = SourceFile('loopx/owner.py', '.py', + 'raise RuntimeError("source must not execute")\nclass Action(str, Enum):\n RUN: str = "run"\n') + assert generator.enum_members(source, 'Action', strict=True) == {'RUN': 'run'} diff --git a/tests/architecture/test_semantic_production.py b/tests/architecture/test_semantic_production.py new file mode 100644 index 0000000000..f4f7eefadc --- /dev/null +++ b/tests/architecture/test_semantic_production.py @@ -0,0 +1,263 @@ +"""Guard the producer/owner distinction using independent finite counterexamples.""" +from __future__ import annotations + +import pytest + +from loopx.semantics.production import collect_production, validate_production, quota_action_domain +from loopx.semantics.python_production import Production +from loopx.semantics.inventory import SourceFile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SITE = 'loopx/control_plane/quota/probe.py::emit' + + +def test_composed_field_does_not_widen_canonical_return_or_establish_owner_liveness(): + v = vocabulary() + field_domain = {'run', 'wait', 'frontier_wait'} + normal = Production(SITE, 1, 'dict', frozenset({'run', 'wait'}), False) + frontier = Production(SITE, 2, 'dict', frozenset({'frontier_wait'}), False) + assert validate_production('action', v, [normal, frontier], field_domain=field_domain) == [] + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', v, [frontier], field_domain=field_domain) + returned = Production(SITE, 3, 'return', frozenset({'frontier_wait'}), False) + with pytest.raises(ValueError, match='unregistered values'): + validate_production('action', v, [normal, returned], field_domain=field_domain) + foreign = Production(SITE + '_undeclared', 4, 'dict', frozenset({'frontier_wait'}), False) + with pytest.raises(ValueError, match='undeclared producer'): + validate_production('action', v, [normal, foreign], field_domain=field_domain) + + +@pytest.mark.parametrize('mutation', ['missing', 'widened', 'overlap']) +def test_quota_union_cannot_be_weakened_or_made_ambiguous(mutation): + registry = {'vocabularies': { + 'effective_action': {'values': ['normal_run']}, + 'agent_scope_frontier_action': {'values': ['agent_scope_wait']}, + }, 'relations': {'shared_field_names': [{'field': 'effective_action', 'slots': [{ + 'slot': 'should_run.effective_action', + 'vocabularies': ['effective_action', 'agent_scope_frontier_action'], + }]}]}} + assert quota_action_domain(registry) == {'normal_run', 'agent_scope_wait'} + if mutation == 'missing': + registry['relations']['shared_field_names'] = [] + elif mutation == 'widened': + registry['relations']['shared_field_names'][0]['slots'][0]['vocabularies'].append('lease_action') + else: + registry['vocabularies']['agent_scope_frontier_action']['values'].append('normal_run') + with pytest.raises(ValueError, match='anchored|disjoint'): + quota_action_domain(registry) + + +def vocabulary(): + return {'values': ['run', 'wait'], 'producers': [SITE], 'owners': {'python': None}, + 'literal_scan': {'field': 'action'}} + + +def row(value, *, site=SITE, unresolved=False): + return Production(site, 1, 'return', frozenset([value]) if value else frozenset(), unresolved) + + +def test_owner_values_never_satisfy_production_liveness(): + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', vocabulary(), [row('run')]) + + +def test_undefined_producer_value_is_rejected(): + with pytest.raises(ValueError, match='unregistered values'): + validate_production('action', vocabulary(), [row('run'), row('typo')]) + + +def test_registering_an_unrelated_function_does_not_cover_a_writer(): + with pytest.raises(ValueError, match='undeclared producer sites'): + validate_production('action', vocabulary(), [row('run'), row('wait', site=SITE.replace('emit', 'hidden'))]) + + +def test_dynamic_path_remains_visible_and_cannot_supply_missing_value(): + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', vocabulary(), [row('run'), row(None, unresolved=True)]) + unknown = validate_production('action', vocabulary(), [row('run'), row('wait'), row(None, unresolved=True)]) + assert unknown == [SITE + ':1'] + + +def test_compatibility_values_must_have_no_observed_production(): + v = vocabulary() + v['compatibility_only'] = {'wait': {'reason': 'Old reader', 'retirement': 'M1'}} + assert validate_production('action', v, [row('run')]) == [] + with pytest.raises(ValueError, match='compatibility-only values are produced'): + validate_production('action', v, [row('run'), row('wait')]) + + +@pytest.mark.parametrize('source, expected', [ + ("function emit() { return {action: flag === 'condition' ? 'run' : 'wait'}; }", {'run', 'wait'}), + ("function emit() { output['action'] = 'run'; output.action = 'wait'; }", {'run', 'wait'}), + ("function read() { if (p.action === 'run') console.log('action'); }", set()), + ("// action: 'comment'\nconst example = `action: 'example'`;", set()), +]) +def test_typescript_parser_observes_results_not_context(source, expected): + rows = collect_production(ROOT, vocabulary(), [SourceFile('loopx/control_plane/quota/probe.ts', '.ts', source)]) + assert set().union(*(r.values for r in rows)) == expected + + +def test_declared_return_is_scanned_in_real_python_syntax(): + v = vocabulary() + v['return_producers'] = [SITE] + rows = collect_production(ROOT, v, [SourceFile(SITE.split('::')[0], '.py', 'def emit():\n return "unregistered"\n')]) + with pytest.raises(ValueError, match='unregistered'): + validate_production('action', v, rows) + + +@pytest.mark.parametrize('vocabulary_name, module, original', [ + ('turn_route', 'loopx/control_plane/turn_driver/driver.py', 'return LoopXTurnRoute.CONTRACT_ERROR'), + ('loop_disposition', 'loopx/control_plane/turn_driver/loop_controller.py', 'LoopXTurnRoute.READY_FOR_HOST: LoopDisposition.RUN_NOW'), +]) +def test_real_return_producer_rejects_an_unregistered_result(vocabulary_name, module, original): + import json + from loopx.semantics.inventory import load_sources + v = json.loads((ROOT / 'loopx/semantics/vocabulary_v0.json').read_text())['vocabularies'][vocabulary_name] + sources = load_sources(ROOT) + replacement = 'return "unknown_action"' if vocabulary_name == 'turn_route' else 'LoopXTurnRoute.READY_FOR_HOST: "unknown_action"' + found = False + mutated = [] + for source in sources: + if source.path == module: + assert original in source.text + source = SourceFile(source.path, source.suffix, source.text.replace(original, replacement, 1)) + found = True + mutated.append(source) + assert found + with pytest.raises(ValueError, match='producer writes unregistered values'): + validate_production(vocabulary_name, v, collect_production(ROOT, v, mutated)) + + +def test_real_turn_decoder_supplies_typed_input_witnesses(): + from loopx.semantics.production import probe_turn_result_input_domain + values = ['validated_progress', 'validated_completion', 'repair_required', + 'replan_required', 'user_action_required', 'wait', 'iteration_failed', + 'host_failure', 'validation_failed', 'writeback_failed', + 'quota_spend_failed', 'terminal_closeout_failed'] + v = {'values': values, 'input_producer': 'loopx/control_plane/turn_driver/transaction.py::_result_kind'} + rows = probe_turn_result_input_domain(v) + assert {r.form for r in rows} == {'input_witness'} + assert set().union(*(r.values for r in rows)) == set(values) + assert all(not r.unresolved for r in rows) + + +@pytest.mark.parametrize('defect', ['constant_result', 'untyped_result', 'unknown_admitted']) +def test_input_witness_probe_rejects_decoder_contract_regressions(monkeypatch, defect): + from types import SimpleNamespace + from loopx.control_plane.turn_driver import transaction + from loopx.semantics.production import probe_turn_result_input_domain + original = transaction._result_kind + + def defective(value, errors): + if defect == 'constant_result': + return transaction.LoopXTurnResultKind.WAIT + if defect == 'untyped_result': + return SimpleNamespace(value=value) + if value == 'unknown_result_kind': + return transaction.LoopXTurnResultKind.WAIT + return original(value, errors) + + monkeypatch.setattr(transaction, '_result_kind', defective) + v = {'values': ['repair_required'], 'input_producer': 'loopx/control_plane/turn_driver/transaction.py::_result_kind'} + with pytest.raises(ValueError, match='decoder'): + probe_turn_result_input_domain(v) + + +def test_legacy_lease_values_stay_visible_without_claiming_production(): + import json + from loopx.semantics.inventory import load_sources + v = json.loads((ROOT / 'loopx/semantics/vocabulary_v0.json').read_text())['vocabularies']['lease_action'] + assert v['status'] == 'legacy' + assert v['producers'] == [] + assert set(v['compatibility_only']) == {'acquire', 'renew', 'transfer', 'release'} + rows = collect_production(ROOT, v, load_sources(ROOT)) + assert not any(r.values for r in rows) + assert validate_production('lease_action', v, rows) == [] + + +def test_new_lease_producer_invalidates_compatibility_only_claim(): + import json + from loopx.semantics.inventory import load_sources + v = json.loads((ROOT / 'loopx/semantics/vocabulary_v0.json').read_text())['vocabularies']['lease_action'] + sources = load_sources(ROOT) + [SourceFile('loopx/control_plane/coordination/new_writer.py', '.py', + 'from .authority_core import LeaseAction\ndef emit():\n return LeaseAction.ACQUIRE\n')] + with pytest.raises(ValueError, match='compatibility-only values are produced'): + validate_production('lease_action', v, collect_production(ROOT, v, sources)) + + +def test_typescript_syntax_failure_reports_only_source_location(): + source = SourceFile('loopx/control_plane/quota/broken.ts', '.ts', 'const secret = "fixture-only";\nfunction invalid( {') + with pytest.raises(ValueError, match=r'broken.ts:2: invalid TypeScript source') as error: + collect_production(ROOT, vocabulary(), [source]) + assert 'fixture-only' not in str(error.value) + + +@pytest.mark.parametrize('suffix, text', [ + ('.py', 'def project(value):\n return {"action": value}\n'), + ('.ts', 'function project(value) { return {action: value}; }'), +]) +def test_generic_effect_files_are_scanned_without_widening_to_all_control_plane(suffix, text): + effect = 'loopx/control_plane/effect_program' + suffix + sibling = 'loopx/control_plane/unrelated' + suffix + rows = collect_production(ROOT, vocabulary(), [SourceFile(effect, suffix, text), SourceFile(sibling, suffix, text)]) + assert {r.site.split('::')[0] for r in rows} == {effect} + assert all(r.unresolved and not r.values for r in rows) + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', vocabulary(), rows) + + +@pytest.mark.parametrize('suffix,text', [ + ('.ts', 'function turn() { return {action: null}; }\nfunction quota(input) { return {action: input.action}; }'), + ('.py', 'def turn():\n return {"action": None}\ndef quota(value):\n return {"action": value}\n'), +]) +def test_effect_null_and_quota_passthrough_have_distinct_evidence(suffix, text): + rows = collect_production(ROOT, vocabulary(), [SourceFile( + 'loopx/control_plane/effect_program' + suffix, suffix, text, + )]) + assert {r.site.rsplit('::', 1)[1]: (r.values, r.unresolved) for r in rows} == { + 'turn': (frozenset(), False), 'quota': (frozenset(), True), + } + + +def test_real_effect_adapters_are_observed_as_unresolved_passthrough(): + import json + from loopx.semantics.inventory import load_sources + + v = json.loads((ROOT / 'loopx/semantics/vocabulary_v0.json').read_text())['vocabularies']['effective_action'] + paths = {'loopx/control_plane/effect_program.py', 'loopx/control_plane/effect_program.ts'} + owner = v['owners']['python'].split('::')[0] + sources = [source for source in load_sources(ROOT) if source.path in paths | {owner}] + rows = collect_production(ROOT, v, sources) + assert {r.site.split('::')[0] for r in rows} == paths + assert not set().union(*(r.values for r in rows)) + assert {r.site.split('::')[0] for r in rows if r.unresolved} == paths + + +def test_explicit_call_builder_metadata_binds_only_actual_tracked_parameters(): + v = vocabulary() + v['owners']['python'] = 'loopx/control_plane/quota/owner.py::Action' + v['call_producers'] = {'loopx/control_plane/quota/builder.py::emit': ['verdict']} + sources = [ + SourceFile('loopx/control_plane/quota/owner.py', '.py', 'class Action:\n RUN="run"\n WAIT="wait"\n'), + SourceFile('loopx/control_plane/quota/builder.py', '.py', 'def emit(verdict, *, reason):\n return {"verdict": verdict}\n'), + SourceFile(SITE.split('::')[0], '.py', 'from .owner import Action\nfrom .builder import emit as output\ndef emit():\n output(Action.RUN, reason=Action.WAIT)\n'), + ] + rows = collect_production(ROOT, v, sources) + assert set().union(*(r.values for r in rows)) == {'run'} + v['call_producers']['loopx/control_plane/quota/builder.py::emit'] = ['nonexistent'] + with pytest.raises(ValueError, match='builder signature'): + collect_production(ROOT, v, sources) + + +def test_registered_consumer_cannot_replace_a_removed_writer(): + v = vocabulary() + owner = 'loopx/control_plane/quota/owner.py' + v['owners']['python'] = owner + '::Action' + sources = [ + SourceFile(owner, '.py', 'class Action:\n RUN="run"\n WAIT="wait"\n'), + SourceFile(SITE.split('::')[0], '.py', + 'from .owner import Action\ndef emit(packet):\n choices = (Action.RUN, Action.WAIT)\n return predicate(packet, choices)\n'), + ] + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', v, collect_production(ROOT, v, sources)) diff --git a/tests/architecture/test_semantic_python_production.py b/tests/architecture/test_semantic_python_production.py new file mode 100644 index 0000000000..85c6f96e0c --- /dev/null +++ b/tests/architecture/test_semantic_python_production.py @@ -0,0 +1,278 @@ +"""Semantic counterexamples for the bounded producer observation relation.""" +from __future__ import annotations + +import pytest + +from loopx.semantics.inventory import SourceFile +from loopx.semantics.python_production import scan_python_production + +OWNER = 'loopx/quota/owner.py::Action' +ENUMS = {OWNER: {'RUN': 'run', 'WAIT': 'wait'}} + + +def scan(text, *, returns=(), path='loopx/quota/client.py', calls=None, paths=None): + return scan_python_production(SourceFile(path, '.py', text), field='action', enums=ENUMS, + return_functions=frozenset(returns), call_arguments=calls, return_paths=paths) + + +def known(rows): + return set().union(*(r.values for r in rows if r.form != 'keyword_unproved')) + + +def test_owner_definition_does_not_produce_values(): + assert known(scan('class Action:\n RUN = "run"\n WAIT = "wait"\n', path=OWNER.split('::')[0])) == set() + + +def test_aliased_import_enum_return_and_keyword_produce_values(): + rows = scan('from .owner import Action as A\ndef emit():\n p = Packet(action=A.RUN.value)\n return A.WAIT\n', + calls={'loopx/quota/client.py::Packet': {'action': 0}}) + assert known(rows) == {'run', 'wait'} + assert {r.site for r in rows} == {'loopx/quota/client.py::emit'} + + +def test_local_owner_use_counts_but_definition_does_not(): + rows = scan('class Action:\n RUN = "run"\n WAIT = "wait"\ndef emit():\n return Action.RUN.value\n', path=OWNER.split('::')[0]) + assert known(rows) == {'run'} + + +def test_comparison_and_read_keys_are_not_production(): + rows = scan('from .owner import Action\ndef read(p):\n if p["action"] == Action.RUN.value:\n return p.get("action")\n') + assert known(rows) == set() + + +def test_registered_return_function_includes_only_its_own_returns(): + rows = scan('def emit(flag):\n def inner():\n return "inner"\n return "left" if flag == "condition" else "right"\n', returns=['emit']) + assert known(rows) == {'left', 'right'} + assert {r.site for r in rows} == {'loopx/quota/client.py::emit'} + + +def test_single_local_variable_and_reassignment_boundary(): + rows = scan('def emit(flag):\n code = "run" if flag else "wait"\n return code\n', returns=['emit']) + assert known(rows) == {'run', 'wait'} + assert not any(r.unresolved for r in rows) + rows = scan('def emit(flag):\n code = "run"\n if flag:\n code = dynamic()\n return code\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_parameter_shadowing_does_not_borrow_owner_values(): + rows = scan('from .owner import Action\ndef emit(Action):\n return Action.RUN.value\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_same_name_import_from_wrong_module_is_unknown(): + rows = scan('from .unrelated import Action\ndef emit():\n return Action.RUN.value\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_unknown_member_fails_with_location(): + with pytest.raises(ValueError, match=r'client.py:3: unknown owner member MISSING'): + scan('from .owner import Action\ndef emit():\n return Action.MISSING.value\n') + + +def test_unresolved_result_preserves_conditional_literal_evidence(): + rows = scan('def emit(flag):\n return "run" if flag else dynamic()\n', returns=['emit']) + assert known(rows) == {'run'} + assert rows[0].unresolved + + +def test_field_write_sites_are_attributed_to_distinct_functions(): + rows = scan('def first():\n return {"action": "run"}\ndef second():\n return Packet(action="wait")\n', + calls={'loopx/quota/client.py::Packet': {'action': 0}}) + assert {(r.site, tuple(r.values)) for r in rows} == { + ('loopx/quota/client.py::first', ('run',)), + ('loopx/quota/client.py::second', ('wait',)), + } + + +def test_local_import_shadowing_does_not_borrow_owner_values(): + rows = scan('from .owner import Action\ndef emit():\n from .unrelated import Action\n return Action.RUN.value\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_assignment_after_return_is_not_a_variable_definition(): + rows = scan('def emit():\n return code\n code = "run"\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_module_rebind_of_builtin_str_is_unknown(): + rows = scan('str = custom\ndef emit():\n return str("run")\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_enum_used_only_as_mapping_key_does_not_produce_that_enum(): + rows = scan('from .owner import Action\ndef explain(value):\n reasons = {Action.RUN: "text"}\n return reasons[value]\n') + assert known(rows) == set() + + +def test_enum_comparison_inside_result_packet_does_not_produce_operand(): + rows = scan('from .owner import Action\ndef explain(value):\n packet = {"ok": value == Action.RUN}\n return packet\n') + assert known(rows) == set() + + +def test_dictionary_lookup_result_includes_values_not_keys(): + rows = scan('from .owner import Action\ndef route(value):\n return {"x": Action.RUN, "y": Action.WAIT}[value]\n', returns=['route']) + assert known(rows) == {'run', 'wait'} + assert any(row.unresolved for row in rows) + + +def test_local_enum_dispatch_table_is_a_consumer_not_a_producer(): + from loopx.semantics.production import validate_production + + rows = scan('from .owner import Action\ndef is_quiet(packet):\n choices = (Action.RUN.value, Action.WAIT.value)\n return packet.get("action") in choices\n') + assert known(rows) == set() + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', {'values': ['run', 'wait'], 'producers': []}, rows) + + +def test_local_enum_container_can_feed_a_real_scalar_write(): + rows = scan('from .owner import Action\ndef emit():\n choices = (Action.RUN.value, Action.WAIT.value)\n return {"action": choices[1]}\n') + assert known(rows) == {'wait'} + assert {r.form for r in rows} == {'dict'} + + +@pytest.mark.parametrize('container', [ + '(Action.RUN.value, Action.WAIT.value)', + '{Action.RUN.value, Action.WAIT.value}', + '{"first": Action.RUN.value, "second": Action.WAIT.value}', +]) +@pytest.mark.parametrize('use', ['return predicate(choices)', 'predicate(allowed=choices)', + 'return packet.get("action") in choices']) +def test_enum_containers_only_used_by_predicates_cannot_establish_liveness(container, use): + rows = scan(f'from .owner import Action\ndef consume(packet):\n choices = {container}\n {use}\n') + assert known(rows) == set() + + +@pytest.mark.parametrize('expression', [ + 'predicate((Action.RUN, Action.WAIT))', + 'predicate(allowed={Action.RUN, Action.WAIT})', + 'predicate(allowed={"x": Action.RUN})', + 'predicate(Action.RUN)', + 'predicate(action=(Action.RUN, Action.WAIT))', +]) +def test_arbitrary_calls_are_not_enum_output_builders(expression): + assert known(scan(f'from .owner import Action\ndef consume():\n return {expression}\n')) == set() + + +@pytest.mark.parametrize('container,index,expected', [ + ('(Action.RUN.value, Action.WAIT.value)', '1', {'wait'}), + ('[Action.RUN.value, Action.WAIT.value]', '-1', {'wait'}), + ('{"first": Action.RUN.value, "second": Action.WAIT.value}', '"first"', {'run'}), +]) +def test_local_container_aliases_resolve_only_selected_output_elements(container, index, expected): + rows = scan(f'from .owner import Action\ndef emit():\n choices = {container}\n alias = choices\n return {{"action": alias[{index}]}}\n') + assert known(rows) == expected + assert not any(r.unresolved for r in rows) + + +def test_enum_container_written_as_a_scalar_is_unknown_not_two_produced_actions(): + rows = scan('from .owner import Action\ndef emit():\n choices = (Action.RUN, Action.WAIT)\n return {"action": choices}\n') + assert known(rows) == set() + assert rows and all(row.unresolved for row in rows) + + +def test_returned_tuple_needs_an_explicit_scalar_output_path(): + # Use a neutral local name: an assignment named `action` is itself one of + # the scanner's explicitly supported field-write forms. + text = 'from .owner import Action\ndef emit():\n selected = Action.RUN.value\n return selected, "reason"\n' + assert known(scan(text)) == set() + rows = scan(text, returns=['emit'], paths={'emit': (0,)}) + assert known(rows) == {'run'} + + +def test_returning_allowed_values_does_not_witness_scalar_liveness(): + from loopx.semantics.production import validate_production + + rows = scan('from .owner import Action\ndef allowed():\n return Action.RUN.value, Action.WAIT.value\n') + assert known(rows) == set() + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', {'values': ['run', 'wait'], 'producers': []}, rows) + + +def test_field_named_predicate_keywords_cannot_supply_liveness(): + from loopx.semantics.production import validate_production + + rows = scan('from .owner import Action\ndef query():\n return predicate(action=Action.RUN.value) or predicate(action=Action.WAIT.value)\n') + assert known(rows) == set() + assert rows and all(row.unresolved for row in rows) + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', {'values': ['run', 'wait'], 'producers': []}, rows) + # Still retain the literal's closedness evidence when its output role is unknown. + rows = scan('def query():\n return predicate(action="unregistered")\n') + with pytest.raises(ValueError, match='unregistered values'): + validate_production('action', {'values': ['run'], 'producers': []}, rows) + + +def test_explicit_output_builder_tracks_import_alias_and_only_declared_argument(): + rows = scan('from .owner import Action\nfrom .builder import emit as build\ndef run():\n build(Action.RUN, context=Action.WAIT)\n', + calls={'loopx/quota/builder.py::emit': {'verdict': 0}}) + assert known(rows) == {'run'} + assert {r.form for r in rows} == {'call_argument'} + + +@pytest.mark.parametrize('prefix,parameters,body', [ + ('from .wrong import emit', '', 'emit(Action.RUN)'), + ('from .builder import emit', 'emit', 'emit(Action.RUN)'), + ('from .builder import emit\nemit = predicate', '', 'emit(Action.RUN)'), + ('from .builder import emit', '', 'emit = predicate\n emit(Action.RUN)'), + ('from .builder import emit', '', 'from .wrong import emit\n emit(Action.RUN)'), +]) +def test_wrong_or_shadowed_builder_cannot_borrow_output_evidence(prefix, parameters, body): + rows = scan(f'from .owner import Action\n{prefix}\ndef run({parameters}):\n {body}\n', + calls={'loopx/quota/builder.py::emit': {'verdict': 0}}) + assert known(rows) == set() + + +def test_known_and_unknown_builder_arguments_preserve_closedness_evidence(): + rows = scan('from .builder import emit\ndef run(flag):\n emit("typo" if flag else dynamic())\n', + calls={'loopx/quota/builder.py::emit': {'verdict': 0}}) + assert known(rows) == {'typo'} + assert rows[0].unresolved + + +def test_exhaustive_branch_selection_produces_only_at_output(): + body = ('from .owner import Action\ndef emit(flag):\n' + ' if flag:\n choice = Action.RUN\n else:\n choice = Action.WAIT\n') + assert known(scan(body + ' return {"action": choice.value}\n')) == {'run', 'wait'} + assert known(scan(body + ' return predicate(choice)\n')) == set() + + +def test_declared_return_path_does_not_borrow_sibling_values(): + rows = scan('def emit():\n packet = {"route": {"kind": "run"}, "diagnostic": "not-an-action"}\n return packet\n', + returns=['emit'], paths={'emit': ('route', 'kind')}) + assert known(rows) == {'run'} + assert not any(r.unresolved for r in rows) + + +def test_dynamic_lookup_retains_known_possibilities_and_unknown_boundary(): + rows = scan('from .owner import Action\ndef emit(index):\n choices = (Action.RUN, dynamic())\n return {"action": choices[index]}\n') + assert known(rows) == {'run'} + assert rows[0].unresolved + + +@pytest.mark.parametrize('mutation', ['choices[0] = dynamic()', 'alias[0] = dynamic()', + 'choices.clear()', 'predicate(choices)']) +def test_mutated_or_escaped_local_container_does_not_reuse_stale_elements(mutation): + rows = scan('from .owner import Action\ndef emit():\n choices = [Action.RUN]\n alias = choices\n ' + + mutation + '\n return {"action": alias[0]}\n') + assert known(rows) == set() + assert rows and all(row.unresolved for row in rows) + + +@pytest.mark.parametrize('index', ['99', '"not-an-index"', '1:', 'None']) +def test_non_scalar_or_invalid_literal_lookup_cannot_produce_scalar_action(index): + rows = scan(f'from .owner import Action\ndef emit():\n choices = (Action.RUN, Action.WAIT)\n return {{"action": choices[{index}]}}\n') + assert known(rows) == set() + assert rows[0].unresolved + + +@pytest.mark.parametrize('value', ['"run"', 'Action.RUN.value']) +def test_value_attribute_requires_an_enum_object_not_a_serialized_string(value): + rows = scan(f'from .owner import Action\ndef emit():\n choice = {value}\n return {{"action": choice.value}}\n') + assert known(rows) == set() + assert rows[0].unresolved diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 50d9434045..52ba5fdf36 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -63,3 +63,169 @@ def test_literal_scan_rejects_unknown_value_with_either_quote(suffix: str, quote sources = [smoke["SourceFile"]("loopx/probe" + suffix, suffix, text)] with pytest.raises(smoke["Drift"], match="unregistered_action"): smoke["check_literal_vocabularies"](smoke["load_registry"](), sources) + + +def test_bounded_producer_scan_rejects_unregistered_write() -> None: + smoke = runpy.run_path(str(SMOKE)) + source = smoke["SourceFile"]( + "loopx/control_plane/quota/probe.py", + ".py", + 'def produce():\n return {"effective_action": "unregistered_action"}\n', + ) + with pytest.raises(smoke["Drift"], match="unregistered_action"): + smoke["check_producers"]( + { + "relations": {"shared_field_names": [{"field": "effective_action", "slots": [{ + "slot": "should_run.effective_action", + "vocabularies": ["effective_action", "agent_scope_frontier_action"], + }]}]}, + "vocabularies": { + "agent_scope_frontier_action": {"values": ["frontier_wait"]}, + "effective_action": { + "tier": "kernel", + "owners": {"python": None, "typescript": None}, + "values": ["registered_action"], + "producers": ["loopx/control_plane/quota/probe.py::produce"], + "literal_scan": {"field": "effective_action", "roots": ["loopx"], "suffixes": [".py"]}, + } + } + }, + [source], + ) + + +def test_bounded_producer_scan_does_not_treat_consumer_reads_as_writes() -> None: + smoke = runpy.run_path(str(SMOKE)) + source = smoke["SourceFile"]( + "loopx/control_plane/quota/probe.py", + ".py", + 'def consume(payload):\n return payload.get("effective_action") == "registered_action"\n', + ) + assert smoke["_producer_literals"]("effective_action", source) == set() + + +@pytest.mark.parametrize('suffix, text, expected', [ + ('.py', 'effective_action == Action.NORMAL.value or state == "not_an_action"', set()), + ('.ts', 'effective_action === Action.NORMAL || state === "not_an_action";', set()), + ('.py', '# effective_action = "comment"\nexample = \'effective_action == "example"\'', set()), + ('.ts', '// effective_action = "comment"\nconst example = \'effective_action === "example"\';', set()), + ('.py', 'effective_action = "run" if state == "condition" else "wait"', {'run', 'wait'}), + ('.ts', 'effective_action = state === "condition" ? "run" : "wait";', {'run', 'wait'}), + ('.py', 'str(packet.get("effective_action") or "") in {"run", "wait"}', {'run', 'wait'}), + ('.ts', '["run", "wait"].includes(packet.effective_action);', {'run', 'wait'}), + ('.py', 'match packet["effective_action"]:\n case "run" | "wait": pass', {'run', 'wait'}), + ('.ts', 'switch (packet.effective_action) { case "run": break; case "wait": break; }', {'run', 'wait'}), + ('.ts', 'const packet = {["effective_action"]: "run"};', {'run'}), + ('.ts', 'const packet = {[`effective_action`]: "run"};', {'run'}), + ('.ts', 'const packet = {[effective_action]: "not_a_static_key"};', set()), + ('.py', '(p.get("effective_action") and p["state"]) == "eligible"', set()), + ('.py', '(p.get("effective_action") or p["state"]) == "eligible"', set()), + ('.ts', '(packet.effective_action || packet.state) === "eligible";', set()), + ('.ts', '(packet.effective_action ?? "") === "run";', {'run'}), +]) +def test_literal_uses_belong_to_the_field_not_neighboring_syntax(suffix, text, expected): + smoke = runpy.run_path(str(SMOKE)) + source = smoke['SourceFile']('loopx/probe' + suffix, suffix, text) + assert set(smoke['scan_literals']('effective_action', ['loopx'], [suffix], [source])) == expected + + +@pytest.mark.parametrize('value', ['normal_run', 'agent_scope_wait']) +@pytest.mark.parametrize('suffix', ['.py', '.ts']) +def test_registered_root_action_literals_still_require_owner_import(value, suffix): + smoke = runpy.run_path(str(SMOKE)) + source = smoke['SourceFile']('loopx/probe' + suffix, suffix, f'effective_action = "{value}"') + with pytest.raises(smoke['Drift'], match='import EffectiveAction or AgentScopeFrontierAction'): + smoke['check_literal_vocabularies'](smoke['load_registry'](), [source]) + + +def test_real_monitor_membership_cannot_revert_to_bare_action_literals(): + smoke = runpy.run_path(str(SMOKE)) + path = 'loopx/control_plane/quota/monitor_poll_commit.ts' + source = (REPO_ROOT / path).read_text() + old = 'AgentScopeFrontierAction.AGENT_SCOPE_WAIT, EffectiveAction.MONITOR_QUIET_SKIP' + assert old in source + changed = source.replace(old, '"agent_scope_wait", "monitor_quiet_skip"') + with pytest.raises(smoke['Drift'], match='bare action literals'): + smoke['check_literal_vocabularies'](smoke['load_registry'](), [smoke['SourceFile'](path, '.ts', changed)]) + + +@pytest.mark.parametrize('name, metadata, selection', [ + ('effective_action', 'call_producers', ['state']), + ('loop_disposition', 'call_producers', ['state']), + ('agent_scope_frontier_action', 'return_paths', ['action']), + ('turn_route', 'return_paths', ['action']), +]) +def test_registry_cannot_add_unanchored_output_selectors(name, metadata, selection): + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke['load_registry']()) + registry['vocabularies'][name].setdefault(metadata, {})[ + 'loopx/control_plane/quota/decision_summary.py::quota_effective_action' + ] = selection + with pytest.raises(smoke['Drift'], match='anchored output evidence exactly'): + smoke['check_coverage_floor'](registry) + + +def test_bounded_context_scope_excludes_only_declared_multi_value_fork() -> None: + smoke = runpy.run_path(str(SMOKE)) + registry = smoke["load_registry"]() + sources = smoke["load_sources"](REPO_ROOT) + inventory = smoke["build_inventory"](REPO_ROOT, sources=sources) + assert smoke["check_scope_declarations"](registry, inventory) == 3 + + +def test_bounded_context_scope_requires_every_distinct_defining_module() -> None: + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke["load_registry"]()) + registry["scope_declarations"]["SOURCE_SURFACES"]["contexts"] = registry["scope_declarations"]["SOURCE_SURFACES"]["contexts"][:-1] + sources = smoke["load_sources"](REPO_ROOT) + inventory = smoke["build_inventory"](REPO_ROOT, sources=sources) + with pytest.raises(smoke["Drift"], match="every defining module"): + smoke["check_scope_declarations"](registry, inventory) + + +@pytest.mark.parametrize("text, expected", [ + ('payload["effective_action"] = "new_action"', {"new_action"}), + ('route.effective_action: str = "new_action"', {"new_action"}), + ('Packet(effective_action="new_action")', {"new_action"}), + ('payload = {"effective_action":\n "left" if flag == "condition" else "right"}', {"left", "right"}), + ('effective_action = payload.get("effective_action", "fallback")', set()), + ('effective_action == "not_produced"', set()), + ('# effective_action = "comment"', set()), + ('example = \'effective_action = "example"\'', set()), +]) +def test_python_production_forms_separate_result_from_context(text, expected) -> None: + smoke = runpy.run_path(str(SMOKE)) + source = smoke["SourceFile"]("loopx/control_plane/quota/probe.py", ".py", text) + assert smoke["_producer_literals"]("effective_action", source) == expected + + +def test_return_producer_scope_cannot_be_removed_from_registry(): + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke['load_registry']()) + registry['vocabularies']['effective_action']['return_producers'] = [] + with pytest.raises(smoke['Drift'], match='RETURN_PRODUCER_ANCHOR'): + smoke['check_coverage_floor'](registry) + + +@pytest.mark.parametrize('name', ['turn_route', 'loop_disposition', 'agent_scope_frontier_action']) +def test_registered_kernel_producer_coverage_cannot_be_removed(name): + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke['load_registry']()) + registry['vocabularies'][name].pop('producers') + with pytest.raises(smoke['Drift'], match='PRODUCER_VOCABULARY_ANCHOR'): + smoke['check_coverage_floor'](registry) + + +@pytest.mark.parametrize('metadata,name', [ + ('call_producers', 'loop_disposition'), + ('call_producers', 'agent_scope_frontier_action'), + ('call_producers', 'turn_result_kind'), + ('return_paths', 'turn_route'), + ('return_paths', 'turn_result_kind'), +]) +def test_explicit_output_evidence_cannot_be_removed_or_redirected(metadata, name): + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke['load_registry']()) + registry['vocabularies'][name][metadata] = {} + with pytest.raises(smoke['Drift'], match='anchored output evidence'): + smoke['check_coverage_floor'](registry) diff --git a/tests/control_plane/test_agent_scope_frontier_contract.py b/tests/control_plane/test_agent_scope_frontier_contract.py new file mode 100644 index 0000000000..fc84400a43 --- /dev/null +++ b/tests/control_plane/test_agent_scope_frontier_contract.py @@ -0,0 +1,200 @@ +"""A frontier owns one action slot; quota actions cannot overwrite its domain.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from examples.control_plane.quota_plan_fixtures import ( + SCOPED_AGENT_ID, + write_cli_fixture, +) +from loopx.control_plane.agents.agent_scope_frontier import ( + AgentScopeFrontierAction, + build_agent_scope_frontier_payload, +) +from loopx.control_plane.testing.quota_fixtures import ( + quota_status_payload, + quota_todo_item, +) +from loopx.control_plane.work_items.goal_route_hint import build_goal_route_hint +from loopx.quota import build_quota_should_run, render_quota_should_run_markdown + + +def _frontier(action=AgentScopeFrontierAction.AGENT_SCOPE_WAIT, *, extra_fields=None): + return build_agent_scope_frontier_payload( + agent_id="frontier-fixture", + action=action, + quiet_noop_allowed=True, + spend_policy="no spend while waiting", + reason="no runnable candidate", + recommended_action="wait for the prerequisite", + candidate_counts={}, + extra_fields=extra_fields, + ) + + +@pytest.mark.parametrize("action", list(AgentScopeFrontierAction)) +def test_frontier_has_one_canonical_action_slot(action): + payload = _frontier(action) + assert payload["schema_version"] == "agent_scope_frontier_v1" + assert payload["action"] == action.value + assert "effective_action" not in payload + + +@pytest.mark.parametrize( + "extra", + [ + {"action": "normal_run"}, + {"effective_action": "normal_run"}, + {"schema_version": "agent_scope_frontier_v0"}, + {"action": None}, + {"effective_action": "agent_scope_wait"}, + {"schema_version": "agent_scope_frontier_v1"}, + ], +) +def test_extra_fields_cannot_replace_the_frontier_domain(extra): + with pytest.raises(ValueError, match="frontier.*reserved"): + _frontier(extra_fields=extra) + + +def test_frontier_keeps_bounded_candidate_context(): + payload = _frontier(extra_fields={"priority_preemption": True}) + assert payload["priority_preemption"] is True + + +@pytest.mark.parametrize( + "frontier, expected", + [ + ( + { + "schema_version": "agent_scope_frontier_v0", + "effective_action": "agent_scope_wait", + }, + "agent_scope_wait", + ), + ( + { + "schema_version": "agent_scope_frontier_v0", + "action": "agent_scope_wait", + "effective_action": "agent_scope_wait", + }, + "agent_scope_wait", + ), + ( + { + "schema_version": "agent_scope_frontier_v1", + "action": "successor_replan_required", + }, + "successor_replan_required", + ), + ( + { + "schema_version": "agent_scope_frontier_v1", + "action": "successor_replan_required", + "effective_action": "agent_scope_wait", + }, + "successor_replan_required", + ), + ], +) +def test_goal_route_reads_canonical_action_first_and_legacy_as_fallback( + frontier, expected +): + hint = build_goal_route_hint( + agent_identity={"agent_id": "frontier-fixture"}, + agent_todo_summary={}, + agent_lane_next_action=None, + agent_scope_frontier=frontier, + agent_lane_frontier_hint=None, + active_state_next_action="Preserve the goal route.", + latest_run_recommended_action=None, + selected_recommended_action="Wait.", + ) + assert hint["route_decision"] == expected + assert hint["preserves_goal_next_action"] is True + assert hint["goal_next_action_mutation"] == "none" + + +def test_live_should_run_and_markdown_use_the_v1_frontier(): + status = quota_status_payload( + goal_id="frontier-fixture", + status="active", + recommended_action="Complete the claimed prerequisite.", + coordination={ + "agent_model": "peer_v1", + "registered_agents": ["current-agent", "other-agent"], + }, + agent_todo_items=[ + quota_todo_item( + todo_id="todo_prerequisite", + title="Complete the claimed prerequisite.", + claimed_by="other-agent", + ) + ], + ) + guard = build_quota_should_run( + status, goal_id="frontier-fixture", agent_id="current-agent" + ) + frontier = guard["agent_scope_frontier"] + assert frontier["schema_version"] == "agent_scope_frontier_v1" + assert frontier["action"] == "reassignment_required" + assert "effective_action" not in frontier + assert guard["effective_action"] == "reassignment_required" + assert guard["decision"] == "reassignment_required" + assert guard["should_run"] is False + assert guard["normal_delivery_allowed"] is False + assert guard["goal_route_hint"]["route_decision"] == "reassignment_required" + assert ( + "agent_scope_frontier: action=reassignment_required" + in render_quota_should_run_markdown(guard) + ) + + +def test_cli_should_run_reads_disposable_state_and_emits_v1(tmp_path): + registry, runtime, project = write_cli_fixture(tmp_path, scoped_agents=True) + state = project / ".codex/goals/half-speed/ACTIVE_GOAL_STATE.md" + with state.open("a", encoding="utf-8") as stream: + stream.write( + "\n## Agent Todo\n\n" + "- [ ] [P0] Complete the claimed prerequisite.\n" + " \n" + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry), + "--runtime-root", + str(runtime), + "--format", + "json", + "quota", + "should-run", + "--goal-id", + "half-speed", + "--agent-id", + SCOPED_AGENT_ID, + "--runtime-profile", + "outer_controller", + "--scan-path", + str(project), + ], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + text=True, + check=True, + ) + guard = json.loads(result.stdout) + assert guard["effective_action"] == "reassignment_required" + assert guard["should_run"] is False + assert guard["agent_scope_frontier"]["schema_version"] == "agent_scope_frontier_v1" + assert guard["agent_scope_frontier"]["action"] == "reassignment_required" + assert "effective_action" not in guard["agent_scope_frontier"] diff --git a/tests/control_plane/test_effect_interpreter_packet.py b/tests/control_plane/test_effect_interpreter_packet.py index fe4f0d94f3..c51d1b4f5f 100644 --- a/tests/control_plane/test_effect_interpreter_packet.py +++ b/tests/control_plane/test_effect_interpreter_packet.py @@ -1,12 +1,29 @@ from __future__ import annotations +from copy import deepcopy +from dataclasses import asdict +import json +import sys + +import pytest + from loopx.control_plane.effect_program import ( interpret_quota_should_run_packet, + interpret_turn_result_packet, ) +from loopx.control_plane.effect_runtime import effect_runtime_result +from loopx.control_plane.quota.turn_envelope import build_turn_envelope from loopx.control_plane.scheduler.execution_context import ( scheduler_execution_context_for_runtime_profile, ) from loopx.control_plane.testing.quota_fixtures import quota_status_payload +from loopx.control_plane.turn_driver import ( + build_loopx_turn_plan, + load_loopx_turn_plan_from_journal, + run_loopx_turn_once, + validate_loopx_turn_host_result, +) +from loopx.control_plane.turn_driver.transaction import LoopXTurnResultKind from loopx.quota import build_quota_should_run GOAL_ID = "effect-interpreter-fixture" @@ -256,3 +273,134 @@ def test_effect_turn_carries_scheduler_ack_and_failure_hints() -> None: "--failure", "--execute", ) + + +@pytest.mark.parametrize("result_kind", [kind.value for kind in LoopXTurnResultKind]) +def test_result_runtime_and_python_adapter_expose_verdict_without_action(result_kind): + packet = { + "result_kind": result_kind, + "completed_phases": ["host_execute", "typed_result"], + "failed_phase": "validation", + "next_cli_actions": ["loopx status"], + } + before = deepcopy(packet) + raw = effect_runtime_result("effect.interpret_turn_result", {"packet": packet}) + turn = interpret_turn_result_packet(packet) + assert raw["observation"]["decision"] == turn.observation.decision == result_kind + assert raw["observation"]["effective_action"] is None + assert turn.observation.effective_action is None + assert ( + json.loads(json.dumps(asdict(turn)))["observation"]["effective_action"] is None + ) + assert turn.observation.should_run is False + assert turn.request.context["failed_phase"] == "validation" + assert turn.next_effect.cli_actions == ("loopx status",) + assert packet == before + + +@pytest.mark.parametrize( + "host_action", + [ + "normal_run", + "agent_scope_wait", + "wait", + "foreign_action", + None, + 42, + {"action": "normal_run"}, + ["normal_run"], + ], +) +def test_host_action_cannot_enter_the_quota_slot(host_action): + turn = interpret_turn_result_packet( + {"result_kind": "wait", "effective_action": host_action} + ) + assert turn.observation.decision == "wait" + assert turn.observation.effective_action is None + + +def test_real_executor_uses_verdict_and_replays_persisted_plan(tmp_path): + status = quota_status_payload( + goal_id=GOAL_ID, + status="active", + recommended_action="Advance the bounded slice.", + coordination={"agent_model": "peer_v1", "registered_agents": ["codex-fixture"]}, + agent_todo_items=[ + { + "index": 1, + "todo_id": "todo_effect_fixture", + "text": "[P1] Advance the bounded slice.", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task", + } + ], + ) + packet = build_quota_should_run( + status, + goal_id=GOAL_ID, + agent_id="codex-fixture", + ) + plan = build_loopx_turn_plan( + build_turn_envelope(packet), + host="generic-cli", + execution_mode="isolated-headless", + ) + result = { + "schema_version": "loopx_turn_result_v0", + "turn_key": plan["transaction"]["turn_key"], + "result_kind": "wait", + "completed_phases": ["host_execute", "typed_result"], + } + # The executor's existing host schema rejects this field before interpretation. + invalid = validate_loopx_turn_host_result( + plan, {**result, "effective_action": "normal_run"} + ) + assert invalid["ok"] is False + assert "unsupported host result fields: effective_action" in invalid["errors"] + wrong_verdict = validate_loopx_turn_host_result( + plan, {**result, "result_kind": "normal_run"} + ) + assert wrong_verdict["ok"] is False + assert "unsupported host result kind" in wrong_verdict["errors"] + result_path = tmp_path / "result.json" + result_path.write_text(json.dumps(result), encoding="utf-8") + + def forbidden_effect(*_args, **_kwargs): + pytest.fail( + "a wait result must not write back, spend, or apply scheduler effects" + ) + + kwargs = { + "host_argv": [ + sys.executable, + "-c", + "import pathlib,sys; print(pathlib.Path(sys.argv[1]).read_text())", + str(result_path), + ], + "project": tmp_path, + "runtime_root": tmp_path / "runtime", + "goal_id": GOAL_ID, + "execute": True, + "timeout_seconds": 5, + "writeback": forbidden_effect, + "spend": forbidden_effect, + "scheduler": forbidden_effect, + } + first = run_loopx_turn_once(plan, **kwargs) + assert first["status"] == "stopped" + assert first["result_kind"] == "wait" + assert first["effects"]["host_invoked"] is True + assert first["effects"]["quota_spent"] is False + resumed = load_loopx_turn_plan_from_journal( + tmp_path / "runtime", + goal_id=GOAL_ID, + turn_key=result["turn_key"], + ) + assert resumed == plan + assert resumed["turn_envelope"]["effective_action"] == packet["effective_action"] + replay = run_loopx_turn_once(resumed, **kwargs) + assert replay["replayed"] is True + assert replay["result_kind"] == "wait" + assert not any(replay["effects"].values()) diff --git a/tests/control_plane/test_frontier_envelope_compatibility.py b/tests/control_plane/test_frontier_envelope_compatibility.py new file mode 100644 index 0000000000..8348eec598 --- /dev/null +++ b/tests/control_plane/test_frontier_envelope_compatibility.py @@ -0,0 +1,193 @@ +"""Frontier wire reduction must not rewrite historical signed Turn plans.""" + +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json + +import pytest + +from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, + quota_action_signature_document, + turn_envelope_action_signature_document, +) +from loopx.control_plane.agents.agent_scope_frontier import ( + AgentScopeFrontierAction, + build_agent_scope_frontier_payload, +) +from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan +from loopx.control_plane.turn_driver.journal_store import ( + load_loopx_turn_plan_from_journal, + load_turn_journal, + turn_journal_path, + write_turn_journal_checkpoint, +) +from loopx.control_plane.turn_driver.turn_journal_runtime import ( + interpret_turn_journal_projection, +) + + +# Captured before the v1 writer migration. These characterize historical bytes; +# the field-preservation and mutation assertions below define the invariant. +V0_SIGNATURE_HASHES = { + True: "sha256:c6d7977aef90126a9cff9525be407c69dd62152e61fb9cec15687cd54ef0fe5d", + False: "sha256:8f04ecb05ca2156bd59123ad8df6301122552fc62ce79dd7fadce1772f10a5fc", +} + + +def _legacy_decision(*, action_present=True): + frontier = { + "schema_version": "agent_scope_frontier_v0", + "effective_action": "successor_replan_required", + "blocks_delivery": True, + "quiet_noop_allowed": False, + "requires_replan": True, + "recommended_action": "Resolve the ready successor.", + "spend_policy": "spend after validated successor writeback", + } + if action_present: + frontier["action"] = "successor_replan_required" + return { + "ok": True, + "goal_id": "frontier-fixture", + "agent_identity": {"agent_id": "frontier-agent"}, + "decision": "run", + "should_run": True, + "effective_action": "successor_replan_required", + "state": "eligible", + "recommended_action": "Resolve the ready successor.", + "selected_todo": {"todo_id": "todo_successor", "status": "open"}, + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "successor_replan_required", + "agent_channel": { + "must_attempt": True, + "delivery_allowed": True, + "quiet_noop_allowed": False, + "primary_action": "Resolve the ready successor.", + }, + "user_channel": {"action_required": False, "notify": "DONT_NOTIFY"}, + "cli_channel": {}, + }, + "agent_scope_frontier": frontier, + } + + +def _signature_hash(document): + return ( + "sha256:" + + sha256( + json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + ) + + +@pytest.mark.parametrize("action_present", [True, False]) +def test_v0_frontier_retains_signed_fields(action_present): + source = _legacy_decision(action_present=action_present) + before = deepcopy(source) + envelope = build_turn_envelope(source) + assert source == before + assert ( + envelope["contract_capsule"]["agent_scope_frontier"] + == source["agent_scope_frontier"] + ) + signature = turn_envelope_action_signature_document(envelope) + assert signature == quota_action_signature_document(source) + assert _signature_hash(signature) == envelope["action_signature"]["source_hash"] + assert _signature_hash(signature) == V0_SIGNATURE_HASHES[action_present] + + for key in source["agent_scope_frontier"]: + changed = deepcopy(envelope) + del changed["contract_capsule"]["agent_scope_frontier"][key] + assert ( + _signature_hash(turn_envelope_action_signature_document(changed)) + != V0_SIGNATURE_HASHES[action_present] + ) + + +def test_v1_reduces_only_the_frontier_slot_and_keeps_root_action(): + legacy = _legacy_decision() + source = deepcopy(legacy) + source["agent_scope_frontier"] = build_agent_scope_frontier_payload( + agent_id="frontier-agent", + action=AgentScopeFrontierAction.SUCCESSOR_REPLAN_REQUIRED, + quiet_noop_allowed=False, + requires_replan=True, + candidate_counts={}, + reason="ready successor", + recommended_action="Resolve the ready successor.", + spend_policy="spend after validated successor writeback", + ) + legacy_envelope = build_turn_envelope(legacy) + envelope = build_turn_envelope(source) + capsule = envelope["contract_capsule"]["agent_scope_frontier"] + expected = dict(legacy["agent_scope_frontier"]) + del expected["effective_action"] + expected["schema_version"] = "agent_scope_frontier_v1" + assert capsule == expected + assert ( + envelope["effective_action"] + == legacy_envelope["effective_action"] + == "successor_replan_required" + ) + signature = turn_envelope_action_signature_document(envelope) + legacy_signature = turn_envelope_action_signature_document(legacy_envelope) + assert signature == quota_action_signature_document(source) + assert envelope["action_signature"]["matches"] is True + assert _signature_hash(signature) != _signature_hash(legacy_signature) + # The wire reduction is versioned, signed, and confined to this capsule. + legacy_signature["contract_capsule"]["agent_scope_frontier"] = expected + assert signature == legacy_signature + + +@pytest.mark.parametrize("action_present", [True, False]) +def test_persisted_v0_plan_resumes_without_rewriting_frontier(tmp_path, action_present): + envelope = build_turn_envelope(_legacy_decision(action_present=action_present)) + plan = build_loopx_turn_plan( + envelope, host="generic-cli", execution_mode="isolated-headless" + ) + assert plan["route"]["kind"] == "replan_required" + turn_key = plan["transaction"]["turn_key"] + path = turn_journal_path(tmp_path, goal_id="frontier-fixture", turn_key=turn_key) + journal = { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "frontier-fixture", + "turn_key": turn_key, + "status": "in_progress", + "completed_phases": [], + "plan": plan, + } + write_turn_journal_checkpoint(path, journal) + # Host execution and typed-result validation commit as one checkpoint. + journal["completed_phases"] = ["host_execute", "typed_result"] + write_turn_journal_checkpoint(path, journal) + original_bytes = path.read_bytes() + resumed = load_loopx_turn_plan_from_journal( + tmp_path, goal_id="frontier-fixture", turn_key=turn_key + ) + assert resumed == plan + assert ( + _signature_hash( + turn_envelope_action_signature_document(resumed["turn_envelope"]) + ) + == V0_SIGNATURE_HASHES[action_present] + ) + inspection = interpret_turn_journal_projection( + load_turn_journal(path), + goal_id="frontier-fixture", + agent_id="frontier-agent", + turn_key=turn_key, + ) + assert inspection["journal_consistent"] is True + assert inspection["recovery_decision"]["can_continue"] is True + assert inspection["recovery_decision"]["resume_from"] == "validation" + assert inspection["recovery_decision"]["reinvoke_host"] is False + assert path.read_bytes() == original_bytes diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index c4eddcac93..6d9b4f6453 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2783,6 +2783,8 @@ def test_agent_selection_rejects_unprojected_todo(tmp_path: Path) -> None: assert invalid_rc != 0, invalid assert invalid["ok"] is False assert invalid["error_code"] == "quota_action_selection_rejected" + assert invalid["effective_action"] == "quota_skip" + assert invalid["should_run"] is False assert invalid["action_selection_qualification"] == { "schema_version": "action_selection_qualification_v0", "state": "rejected", @@ -3154,6 +3156,8 @@ def test_selection_added_after_pending_guard_reports_final_boundary( assert rejected_rc == 1, rejected assert rejected["error_code"] == "quota_action_selection_deferred" + assert rejected["effective_action"] == "quota_skip" + assert rejected["should_run"] is False assert rejected["action_selection_qualification"]["reason"] == "control_repair" assert rejected["action_selection_qualification"]["requested_todo_id"] == ( added["todo_id"] diff --git a/tests/control_plane/test_user_gate_lane_progress.py b/tests/control_plane/test_user_gate_lane_progress.py index d6bb8af290..bc42c31c8e 100644 --- a/tests/control_plane/test_user_gate_lane_progress.py +++ b/tests/control_plane/test_user_gate_lane_progress.py @@ -3,6 +3,9 @@ from datetime import datetime, timedelta, timezone from pathlib import Path +import pytest + +from loopx.control_plane.todos.user_gate import apply_scoped_user_gate_fallback_projection from loopx.control_plane.quota.scheduler_ack import ( record_quota_scheduler_ack_for_decision, ) @@ -32,6 +35,30 @@ ) +@pytest.mark.parametrize("action", ["quota_skip", "monitor_quiet_skip", None]) +def test_runnable_user_gate_fallback_replaces_a_canonical_skip_action(action): + original = {"decision": "skip", "should_run": False, "effective_action": action} + result = apply_scoped_user_gate_fallback_projection( + original, fallback={"recommended_action": "advance non-gated work"}, + replan_decision_allowed=False, + ) + assert result["effective_action"] == "scoped_user_gate_fallback" + assert result["should_run"] is True + assert original["should_run"] is False + + +def test_user_gate_fallback_preserves_repair_and_replan_precedence(): + original = {"decision": "run", "should_run": True, "effective_action": "capability_bridge_repair"} + fallback = {"recommended_action": "advance non-gated work"} + result = apply_scoped_user_gate_fallback_projection( + original, fallback=fallback, replan_decision_allowed=False, + ) + assert result["effective_action"] == "capability_bridge_repair" + assert apply_scoped_user_gate_fallback_projection( + original, fallback=fallback, replan_decision_allowed=True, + ) is original + + def _status_payload(*, gate_action_kind: str, blocks_deferred: bool = False) -> dict: completed = quota_todo_item( todo_id="todo_prerequisite", diff --git a/tests/control_plane_ts/effect_program.test.ts b/tests/control_plane_ts/effect_program.test.ts index 0af8caf531..1a54fef53b 100644 --- a/tests/control_plane_ts/effect_program.test.ts +++ b/tests/control_plane_ts/effect_program.test.ts @@ -4,6 +4,8 @@ import test from "node:test"; import { commitStepPayload, effectProgramFromOrderedSteps, + interpretQuotaShouldRunPacket, + interpretTurnResultPacket, requireMatchingEffectId, seedCommittedSteps, settlementBindGate, @@ -45,6 +47,74 @@ const invalidSettlementResult: SettlementResult<{ value: number }> = { }; void invalidSettlementResult; +test("Turn-result verdicts and host actions never become quota actions", () => { + for (const resultKind of ["validated_progress", "repair_required", "wait", "host_failure"]) { + for (const hostAction of [undefined, null, "", "normal_run", "agent_scope_wait", "wait", "foreign_action", 42, { action: "normal_run" }]) { + const packet = { + result_kind: resultKind, + effective_action: hostAction, + failed_phase: "validation", + completed_phases: ["host_execute", "typed_result"], + next_cli_actions: ["loopx status"], + scheduler_hint: { + action: "apply_rrule", cadence_class: "repair", + codex_app: { + ack_hint: { cli_args: ["quota", "scheduler-ack-current"] }, + failure_hint: { cli_args: ["quota", "scheduler-ack-current", "--failure"] }, + }, + }, + }; + const before = structuredClone(packet); + const turn = interpretTurnResultPacket(packet); + const noAction: null = turn.observation.effective_action; + // @ts-expect-error result observations cannot hold even a valid quota action + const foreignAction: typeof noAction = "normal_run"; + void foreignAction; + assert.equal(noAction, null); + assert.equal(turn.observation.decision, resultKind); + assert.equal(turn.observation.should_run, false); + assert.equal(turn.request.context.failed_phase, "validation"); + assert.deepEqual(turn.next_effect, { + cli_actions: ["loopx status"], execution_mode: null, + scheduler_action: "apply_rrule", cadence_class: "repair", + ack_cli_args: ["quota", "scheduler-ack-current"], + failure_cli_args: ["quota", "scheduler-ack-current", "--failure"], + }); + assert.equal(JSON.parse(JSON.stringify(turn)).observation.effective_action, null); + assert.deepEqual(packet, before); + } + } +}); + +test("quota observations retain their string action and ignore host verdict fields", () => { + for (const action of ["normal_run", "quota_skip", "agent_scope_wait", "successor_replan_required"]) { + const turn = interpretQuotaShouldRunPacket({ + decision: "run", should_run: true, effective_action: action, result_kind: "wait", + interaction_contract: { + schema_version: "loopx_interaction_contract_v0", mode: "bounded_delivery", + user_channel: { action_required: false, notify: "DONT_NOTIFY" }, + agent_channel: { must_attempt: true, delivery_allowed: true, quiet_noop_allowed: false }, + cli_channel: {}, + }, + }); + const quotaAction: string = turn.observation.effective_action; + assert.equal(quotaAction, action); + assert.equal(turn.observation.decision, "run"); + assert.equal(turn.observation.should_run, true); + } + assert.throws(() => interpretQuotaShouldRunPacket({}), /interaction_contract must be an object/); +}); + +test("missing or malformed result packets cannot manufacture an action", () => { + for (const packet of [undefined, null, [], "wait", {}, { effective_action: "normal_run" }]) { + const turn = interpretTurnResultPacket(packet); + assert.equal(turn.observation.effective_action, null); + assert.equal(turn.observation.decision, ""); + assert.equal(turn.observation.should_run, false); + assert.deepEqual(turn.next_effect.cli_actions, []); + } +}); + test("ordered Effect Program steps preserve data and skip malformed entries", () => { const program = effectProgramFromOrderedSteps( [ diff --git a/tests/control_plane_ts/turn_journal.test.ts b/tests/control_plane_ts/turn_journal.test.ts index 4ad75accee..1bd362c81e 100644 --- a/tests/control_plane_ts/turn_journal.test.ts +++ b/tests/control_plane_ts/turn_journal.test.ts @@ -112,13 +112,17 @@ test("legal terminal replay is projected without effects or private fields", () assert.deepEqual(input, before); }); -test("journal interpretation preserves the canonical Effect Program slots", () => { +test("journal replay uses its own decision without a quota action slot", () => { const turn = interpretTurnJournalEffect(request()); assert.equal(turn.request.kind, "turn_journal"); assert.equal(turn.interpretation.route, "turn_journal_replay"); assert.equal(turn.observation.decision, "replay_legal"); assert.equal(turn.observation.should_run, false); + assert.equal("effective_action" in turn.observation, false); + const blocked = interpretTurnJournalEffect({...request(), agent_id: "other-agent"}); + assert.equal(blocked.observation.decision, "replay_blocked"); + assert.equal("effective_action" in blocked.observation, false); assert.deepEqual(turn.next_effect.cli_actions, []); assert.deepEqual(interpretTurnJournal(request()), { ok: true, diff --git a/tests/fixtures/turn_envelope_state_matrix.json b/tests/fixtures/turn_envelope_state_matrix.json index 8e8ca8ed45..01a0b0456e 100644 --- a/tests/fixtures/turn_envelope_state_matrix.json +++ b/tests/fixtures/turn_envelope_state_matrix.json @@ -104,7 +104,7 @@ "patch": { "decision": "skip", "should_run": false, - "effective_action": "operator_gate", + "effective_action": "operator_gate_notify", "state": "operator_gate", "normal_delivery_allowed": false, "action_required": true, @@ -168,7 +168,7 @@ "expected": { "decision": "skip", "should_run": false, - "effective_action": "operator_gate", + "effective_action": "operator_gate_notify", "state": "operator_gate", "action.must_attempt": false, "user.action_required": true, diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index 7aa42779b9..7c0f5ea311 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -1180,6 +1180,20 @@ def test_run_once_commits_once_and_replays_without_duplicate_effects( assert count_path.read_text(encoding="utf-8") == "1" assert calls == {"writeback": 1, "spend": 1, "scheduler": 1} + # The route is a persisted compatibility surface, not just in-process state. + # Exercise the actual TypeScript-backed journal writer and Python resume reader. + transaction = plan["transaction"] + assert isinstance(transaction, dict) + turn_key = str(transaction["turn_key"]) + stored = json.loads(turn_journal_path( + tmp_path / "runtime", goal_id="fixture-goal", turn_key=turn_key, + ).read_text(encoding="utf-8")) + assert stored["plan"]["route"]["kind"] == "ready_for_host" + resumed = load_loopx_turn_plan_from_journal( + tmp_path / "runtime", goal_id="fixture-goal", turn_key=turn_key, + ) + assert resumed["route"] == stored["plan"]["route"] + def test_provider_can_commit_before_its_journal_checkpoint( tmp_path: Path, From ab101c286d8616adbc39aeee60bd21b76a06e54a Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 10:35:08 +0800 Subject: [PATCH 2/6] docs(semantics): explain typed domains and compatibility boundaries Signed-off-by: song --- .../semantic-vocabulary-convergence-v0.md | 250 +++++++++++++----- ...emantic-vocabulary-convergence-v0.zh-CN.md | 202 ++++++++++---- docs/reference/effect-interpreter-packet.md | 22 ++ 3 files changed, 369 insertions(+), 105 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 51564280a3..c8b5305404 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -48,10 +48,12 @@ not amend normative sections. vocabulary, forks a constant, adds a carrier, or weakens the registry must edit the registry or regenerate the inventory in the same diff, so the reviewer sees the semantic change as a change. -2. **What remains unchanged.** Runtime behavior, wire formats, and the enum - classes themselves. Each enum keeps living in its owner module; the registry - is checked against code by AST and text scan, it does not generate code and - product code never imports it. +2. **Authority and generation.** Each enum lives in its owner module; the + registry is checked against code by AST and text scan, and product code never + imports it. The M1 generator derives the TypeScript effective-action binding + from the Python owner after checking registry parity. This does not change + wire values or move value authority to the registry; M2's shared contract + generation remains a later milestone. 3. **Default and opt-in boundary.** The check is always on for the repository. It has no runtime flag because it never runs inside the product. 4. **Principal constraint.** Fail closed, deterministic, and not weakenable by @@ -181,7 +183,7 @@ the TypeScript runtime each own one spelling of the same idea. symbols, projections, relations, schema versions, and scanned suffixes is recorded as a floor. An owner is `module::Symbol` or `null`; a bare module path is rejected, and a null owner requires a literal scan. The dispatch - forms the scan recognises are fixed in the smoke. A registry edit therefore + forms the scan recognises are fixed in the scanner code. A registry edit therefore cannot silently narrow what the guard sees. - **I9 Both carrier shapes are measured.** A vocabulary reaches the code either as a string constant (`NAME = "value"`) or as a multi-value carrier (an enum, @@ -205,7 +207,7 @@ the TypeScript runtime each own one spelling of the same idea. producer and must be registered as one. Enforced from M0.5. - **I12 Every kernel value is produced.** For a `kernel` vocabulary, every value not listed under `compatibility_only` has at least one production site - the fixed production forms recognise or a `variable_sourced_values` entry. A + the fixed production forms recognise or an executable witness at a registered input decoder. A variable-source note alone is not production evidence. A value that is only compared is dead or compatibility-only, never canonical. `skip` in `effective_action` is the first expected failure. Enforced from M0.5; at M0 the literal scan accepts a compared value as carried. @@ -315,10 +317,10 @@ definitions in `global_risks.py`, `global_todos.py`, `summary_all.py`, and `pr_review.py` each list the data sources of that one CLI command, and the value sets are meant to differ. It is counted in `multi_value_forks` today and must not be "fixed" by renaming, because a rename lowers the number without -changing the code's meaning. M0.5 adds a `scope` field to the registry with at -least `global` and `bounded_context`, lets a bounded-context name be declared -once with its owning contexts, and removes declared names from the fork -budget (I14, the schema rows below, and the M0.5 row in Section 11). Until +changing the code's meaning. The M0.5 scope slice adds top-level `scope_declarations` with at +least `global` and `bounded_context`; a bounded-context name is declared once +with its owning contexts, and declared names are removed from the semantic +fork budget while the raw inventory count remains visible (I14, the schema rows below, and the M0.5 row in Section 11). Until then the fork budget is a ceiling that contains this one known misclassification, recorded in the registry's `inventory_ratchets` note. @@ -360,6 +362,119 @@ when a value is added or removed after M0.5; `cross_module` only if promoted whose listed symbol is a journal or receipt writer marks the vocabulary `persisted`, which is the fact Q2 and Q10 wait on. +### Executable production evidence during M0.5/M1 + +The producer guard and the owner-carrier check have separate evidence. Defining +an enum member proves membership, not production. For each vocabulary with +producer metadata, the guard compares observed result values against `values`, +rejects undeclared **function sites**, and checks that every non-compatibility +value has an observed producer. A variable-source note is not liveness evidence. +`return_producers` lists the registered functions whose scalar return expressions +belong to this vocabulary; packet builders' unrelated return text is excluded. +`return_paths` selects an explicit field/index path from a returned packet. +`call_producers` names reviewed builder parameters; their module binding and +actual signature are checked against tracked source. These declarations and +their code anchors move together. Selectors equal the code-owned map, with an +empty map for every other vocabulary, so additions also require a code change. +An unconfirmed field-named keyword argument retains closedness/unknown evidence +but cannot establish producer liveness or require producer registration. These +are reviewed output contracts, not automatic proofs of arbitrary helper-body +semantics. Local enum containers and +arguments to arbitrary predicates do not establish production; a selected +scalar must reach an observed output. Mutated or escaped mutable aliases remain +unknown. Generation uses strict enum extraction and rejects unsupported members +before writing any artifact, including when the second owner is invalid. + +Python field assignments (including subscript/attribute and annotated writes), +dictionaries, call keywords, owner-member results and declared scalar returns +are parsed with AST. Imported enum aliases resolve only to the registered owner; +shadowed names, reassignments and unresolved calls remain unknown. Conditional +results exclude the condition's literals. TypeScript object writes, assignments +and declared returns use the repository's TypeScript parser rather than regex. +Neither parser executes inspected source. These are syntactic result witnesses, +not a proof of reachability or whole-program data flow. + +`uv run python examples/semantic-vocabulary-drift-smoke.py --report` lists unresolved +production locations. Unresolved parts cannot supply missing value evidence; known +conditional branches remain structural witnesses, not reachability proofs. +The producer guard covers all six kernel entries using distinct evidence lanes: +`effective_action`, `turn_route`, `loop_disposition`, and +`agent_scope_frontier_action` have source witnesses; `turn_result_kind` also has +executable input witnesses at the fixed `transaction._result_kind` decoder. +For each registered value the real decoder must return the matching typed member; +invalid probes must report rejection. This proves a permitted production path, +not that a Host has emitted every member or that every host execution is valid. +`input_producer` cannot select arbitrary code: the verifier is fixed in the smoke. + +`lease_action` is explicitly legacy/compatibility-only: in-repository runtime +callers use separate acquire/renew/transfer/release command classes. Its four +members remain available to the existing typed `LeaseModeGateCommand` input +interface until M4 caller/migration review. No persisted usage is asserted. +The producer list is empty only because every value carries an explicit reason +and retirement milestone. A newly observed producer invalidates that declaration. Kernel families without producer metadata are printed as coverage pending; their +owner parity must not be reported as I12/I13 completion. M0.5 remains incomplete +until all required families meet its acceptance rows. + +The decision owner includes five existing results previously missed by the +literal scanner: `blocked_health`, `blocked_wait`, `control_plane_repair`, +`operator_gate_notify`, and `throttled_skip`. Registering them preserves the +existing quota behavior. M1 removes the unproduced `skip` and synthetic +`operator_gate` admission. The fallback consumer now recognizes the actual +`quota_skip` action; a runnable scoped fallback must not keep a skip action. +Legacy field retirement remains a separate acceptance obligation. + +Preparation for the TypeScript parser: `npm ci --ignore-scripts` from the +repository root, using its lockfile. The scan itself needs no network or +credentials. Python 3.11+ and the repository-supported Node runtime are required. + +### M1 action domains and compatibility + +Let `D` be the 32 decision values owned by `EffectiveAction`, and `F` the four +values owned by `AgentScopeFrontierAction`. The root should-run and its envelope +projection retain the existing action strings through `A = D ⊔ F`. The registry +anchors the two member vocabularies and checks `D ∩ F = ∅`; hence the value +identifies its domain without a new wire tag. The TypeScript bindings and union +type derive from these owners, not an independently maintained third value list. +This is the registered-union option in Q6. A union arm cannot establish another +owner's producer liveness, and a canonical decision function's scalar return +domain remains `D`. + +| Surface | Current contract | Compatibility | +| --- | --- | --- | +| Root should-run / Turn Envelope `effective_action` | Decision/frontier union `A` | Frontier verdicts retain their meaning and spelling | +| Nested `agent_scope_frontier_v1.action` | Frontier domain `F`; one emitted action field | Readers prefer `action` and retain the old v0 alias as fallback | +| Internal journal replay observation | Existing `decision=replay_legal\|replay_blocked` | Public inspection and stored journal shapes do not change | +| Turn-result Effect observation | Turn verdict in `decision`; `effective_action=null` | Intentional projection change: read `decision` for the verdict; host action fields cannot author a quota decision | +| Action-selection rejection or deferral | `effective_action=quota_skip`; diagnostic in `error_code` | Intentional CLI change: readers distinguish reasons using the unchanged diagnostic code | + +New frontier writes remove the redundant nested `effective_action` and advance +the nested schema to v1. They do not normalize historical signed v0 documents: +the envelope capsule still preserves both legacy keys when present, and journal +resume returns the stored plan unchanged. Compatibility tests characterize old +signatures before the migration, mutate signed fields to prove coverage, and use +the real filesystem journal writer and resume reader. New v1 signatures change +only for the declared nested schema/field reduction. No frontend setting owns +this alias; the quota CLI and Markdown reader are covered by the live tests. + +The transient `effect.interpret_turn_result` projection previously copied either +an arbitrary host action or `result_kind` into the quota action field. It now +emits JSON null, preserved as `None` by the Python adapter. Its TypeScript return +type fixes the action to null; quota observations retain their existing string +action. The executor reads the result's `decision` and persists the normalized +host result and plan, not this transient observation. Real host validation still +rejects unsupported action fields, and executor/journal replay tests cover the +unchanged no-spend wait path. This projection change does not migrate stored +result, receipt or journal schema versions. + +The literal guard uses Python AST and the TypeScript compiler parser for bounded +field writes, comparisons, membership and match/switch cases. It rejects bare +action literals even when registered: import the owner instead. Conditions, +unrelated fields, comments and source examples inside strings do not count as +action values. This is a syntax boundary, not a whole-program data-flow proof; +dynamic keys, aliases and unresolved expressions retain their declared limits. +The generated bindings/glossary freshness check reuses the existing PR pytest +and smoke path; this milestone adds no required CI job. + ### Formal model and proof boundary The registry is a finite specification of a larger program semantics. Let @@ -417,9 +532,10 @@ vocabulary key fails the smoke. | `vocabularies..tier`, `status` | `kernel`, `cross_runtime`, `cross_module`; `canonical`, `legacy`, `merge_candidate` | Closed enumerations | | `vocabularies..literal_scan` | `field`, roots, suffixes | Every literal the fixed dispatch forms capture is registered; every registered value is captured or variable-sourced (I2) | | `vocabularies..variable_sourced_values` | value to producer module | The producer still contains the quoted value | -| `vocabularies..scope` (M0.5) | `global` or `bounded_context`; a `bounded_context` entry lists `contexts`, each with one owner symbol | Closed enumeration; declared bounded-context names are excluded from `multi_value_forks`; an undeclared multi-module name stays a fork (I14) | -| `vocabularies..producers` (M0.5) | `path::Symbol` sites that write the field, required for `kernel` | Every site writes registered values only; every value not under `compatibility_only` has at least one site or a variable-sourced entry (I12, I13) | -| `vocabularies..compatibility_only` (M0.5) | values kept so readers of persisted records still resolve them | Subset of `values`; zero production sites; each carries a `value_notes` reason and a retirement milestone | +| `scope_declarations.` (M0.5a) | `bounded_context` and its context IDs, each with one `module::Symbol` owner | Every declared name resolves to one inventory fork, names every defining module exactly once, and is excluded only from `multi_value_forks_semantic`; undeclared forks remain visible (I14) | +| `vocabularies..input_producer` | Fixed executable decoder witness, currently `turn_result_kind` only | Every registered input produces the matching typed member and invalid probes reject; arbitrary callable selection is forbidden | +| `vocabularies..producers` (M0.5) | `path::Symbol` sites that write the field, required for `kernel` | Every site writes registered values only; every value not under `compatibility_only` has at least one source site or executable input witness (I12, I13) | +| `vocabularies..compatibility_only` (M0.5) | values retained for persisted readers or a legacy typed caller interface | Subset of `values`; zero production sites; each carries a `value_notes` reason and a retirement milestone | | `formal_model` | finite universes, role relations and hierarchy, semantic obligations, and established/bounded/unproved claims | Exact schema, role hierarchy, and invariant ids are checked by the drift smoke; enforcement stages cannot be mistaken for completed proofs | | `formal_model.enforcement_policy` | blocking-now, blocking-next, advisory, and unproved lanes | Every formal invariant appears exactly once and its lane agrees with its enforcement stage | | `vocabularies..value_notes`, `deprecated_values` | per-value review notes; values slated for removal | Names must be registered values | @@ -516,8 +632,8 @@ inventory in the same PR. | Claim | Test or evidence | Required result | Boundary / exclusions | | --- | --- | --- | --- | -| Registry and inventory match the code at baseline | `python3.11 examples/semantic-vocabulary-drift-smoke.py` | `ok` with coverage, ratchet, budget, and twin report | Proves parity for registered vocabularies and mapped carriers only | -| Inventory is fresh | `python3.11 scripts/generate_semantic_inventory.py --check` | exit 0 | Structural map only | +| Registry and inventory match the code at baseline | `uv run python examples/semantic-vocabulary-drift-smoke.py` | `ok` with coverage, ratchet, budget, and twin report | Proves parity for registered vocabularies and mapped carriers only | +| Inventory is fresh | `uv run python scripts/generate_semantic_inventory.py --check` | exit 0 | Structural map only | | Scanner classification rules | `pytest tests/architecture/test_semantic_inventory.py` | pass | Fixture repository; rules from this RFC, not from output | | A widened `effective_action` set fails closed in Python | Add an unregistered literal via `==`, membership, or conditional expression | Failure names the value and file | Mutation exercise; not a committed test | | A widened `effective_action` set fails closed in TypeScript | Add an unregistered literal via `===` or a ternary | Same | Same | @@ -534,11 +650,11 @@ inventory in the same PR. | Measurement covers both carrier shapes and filters local naming | `pytest tests/architecture/test_semantic_inventory.py` | pass, including the collision and module-local-convention fixtures | Rules come from this RFC, not from scanner output | | No behavior change from the two owner fixes | `pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` and `loopx canary premerge --from-git-diff` | pass | Environment failures already present on `main` are excluded when reproduced on a clean tree | | Docs governance accepts the RFC pair | `python3 examples/docs-governance-smoke.py` | pass | Checks mirror, links, index | -| Retirement budgets count substrings, not identifiers | `goal_boundary` counted with `in file.text` and with `\bgoal_boundary\b` | 35 vs 30 Python modules on the baseline | Known boundary; M3's zero-reader gate needs the identifier count, tracked in Section 12 | +| Retirement budgets use standalone field tokens | `count_identifier_modules()` uses identifier boundaries for the six fields | `goal_boundary`: 30 Python modules under the new metric; the old substring metric was 35 | Conservative lexical measure; it removes compound-name false positives but does not prove semantic reader absence | | The module-local convention filter is a code edit | Widen `MODULE_LOCAL_CONVENTION` in `inventory.py` and regenerate | `*_semantic` budgets fall with no code change elsewhere | Known boundary; the regex is in code so the widening is a reviewed diff, and the unfiltered totals stay budgeted | | A registered value nobody produces fails (M0.5) | Run the production-form scan on the baseline | Fails naming `effective_action` and `skip`; passes after `skip` is removed or listed `compatibility_only` | First expected I12 failure; a compared-only value is not carried | | A producer of an unregistered value fails (M0.5) | Write `effective_action: "brand_new"` in a listed producer site | Fails naming the site and the value even though no consumer compares it | I13; production is stricter than comparison | -| A bounded-context name leaves the fork budget only by declaration (M0.5) | Declare `SOURCE_SURFACES` with its four contexts; separately, rename one definition without declaring | The declaration lowers `multi_value_forks` to 3; the rename alone does not | I14; the honest fix is a registry edit a reviewer sees, the rename is code without registry change | +| A bounded-context name leaves only the semantic fork budget by declaration (M0.5a) | Declare `SOURCE_SURFACES` with its four contexts; separately, rename one definition without declaring | Raw `multi_value_forks` stays 4, `multi_value_forks_semantic` is 3; a rename alone changes neither semantic accounting nor declaration | I14; the honest fix is a registry edit a reviewer sees, the rename is not a repair | | An upstream merge can stale the committed inventory | Replay the scanner over the first parent and the merge of the last twenty `upstream/main` merge commits | 8 of 20 merges change at least one carrier | Measured cost of committing a snapshot; the handling rule is Section 10 and Section 12 Q9 | | The formal model cannot silently lose a proof obligation | Remove an invariant, role, relation, or proof-boundary category from `formal_model` | The drift smoke fails on the exact formal-model shape | The model is a finite contract and proof ledger; it does not prove the listed properties by itself | @@ -602,19 +718,33 @@ inventory in a follow-up commit that touches only `inventory_v0.json`, and the smoke's failure text names that command. **Interpreter.** The smoke, the generator, and the scanner require the -project's Python (`>=3.11` in `pyproject.toml`); `zip(strict=True)` fails on -3.9. Fleet and premerge commands are spelled `python3` by repository convention -and run under the CI interpreter. A macOS system `python3` is 3.9, so local -premerge runs need a 3.11 environment on `PATH`; the docs spell the direct -commands as `python3.11` for that reason, and the planner entry is left as -`python3` on purpose. +project's Python (`>=3.11` in `pyproject.toml`), not an executable named +`python3.11`. Run from the checkout with `uv run python