diff --git a/docs/architecture.md b/docs/architecture.md index cdbadac4a4..5801b34052 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -333,8 +333,9 @@ The server path should land in layers: CLI fallbacks for every write. 4. **Heartbeat scheduler**: move recurring heartbeat bookkeeping behind the coordinator only after quota/spend idempotency is proven. Scheduler output - should be the same `quota should-run` / `interaction_contract` / - `protocol_action_packet` shape that current automation prompts already use. + should consume the structured `quota should-run` / `interaction_contract` + output, with `scheduler_hint` owning cadence; historical + `protocol_action_packet` summaries are compatibility observations only. 5. **Planning and dreaming queues**: let background planning produce ranked todo proposals, evidence probes, and refactor warnings as advisory records. These queues must not execute protected work, read private material, or diff --git a/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-09-20-pr05-packet-write-retirement-candidate.md b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-09-20-pr05-packet-write-retirement-candidate.md new file mode 100644 index 0000000000..8c8931706a --- /dev/null +++ b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-09-20-pr05-packet-write-retirement-candidate.md @@ -0,0 +1,47 @@ +# PR-05: retirement of protocol action packet writes + +Refs [#4794](https://github.com/loopx-project/loopx/pull/4794), +[#4447](https://github.com/loopx-project/loopx/issues/4447). The +[protocol decision](../../../../reference/protocols/protocol-action-packet-decision-v0.md) +owns the migration contract. The cutover is the first official release containing #4794, with source builds +following their code revision; published v1.1.0 artifacts stay unchanged. +Historical v0 reads remain supported for the v0 reader lifetime. The protocol +contract names the bundled consumers and v1.1.0 rollback scope. Normal maintainer +review/release controls remain; this entry does not close the tracker. + +- **Delivered delta.** Six live writes across four quota modules are removed, + together with the unused packet builder and its imports. Ordinary, paused, + required-read, capability-intent and host-recovery construction use existing + typed contracts. The default full decision now omits the legacy field, not + merely the compact view. No new flag, schema vocabulary or authority owner. +- **Retained responsibility.** Historical Python Markdown and TypeScript + Effect/Envelope readers, ordered `protocol_action_packet_fields`, summary + reconstruction, opaque fallback, residue and signature rejection remain. + Historical records are never rewritten. The measured Python field surface + falls from 5 to 1; TypeScript remains 2. The same-diff anchors retain those + compatibility readers instead of pretending the field disappeared globally. +- **Behavior evidence.** Eight complete baseline/candidate quota payloads match + after removing only the packet; canonical signature documents match after + removing only its capsule witness. The digest may consequently change. + Real CLI/reentry/Envelope/live tests pass (149). The renamed + `quota-without-legacy-packet-smoke.py` rejects the previous default. The obsolete + decision-note wording smoke is removed; runtime and compatibility regressions + own behavior evidence, while docs governance checks document structure. +- **Versioned readback.** Actual v1.1.0 source (`607c11d75`) reads eight new and + eight packet-bearing baseline samples with unchanged signed actions and valid + host admission. Installed candidate wheel checks ordinary/paused output, + bundled TS/JSON resources, real bridge and host admission. This is bounded + synthetic evidence, not full historical archive or all-host qualification. +- **Durable historical evidence.** Four frozen decision/Envelope objects were + generated by actual v1.1.0 code, with their original hashes retained. Current + readers validate those stored signatures; 24 tampered copies fail admission. + `scripts/verify_protocol_packet_migration.py` requires that exact unmodified + release checkout and checks eight fresh outputs plus the four stored records. + Real CLI replay and combined required-read/capability-intent tests preserve + identity, commands and read order without the packet. +- **Bounded completion.** No default writer remains. Consumer/read-format scope + and rollback steps are concrete in the protocol contract, with no private + client or full-archive promise. External clients requiring the old field must + migrate or retain v1.1.0. Future v0 reader removal needs a separate breaking + migration. Adjacent quota PRs must retain these invariants when rebasing; + their unmerged changes are not silently part of this main-based PR. diff --git a/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-09-20-pr05-packet-write-retirement-candidate.zh-CN.md b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-09-20-pr05-packet-write-retirement-candidate.zh-CN.md new file mode 100644 index 0000000000..161ffcec5d --- /dev/null +++ b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-09-20-pr05-packet-write-retirement-candidate.zh-CN.md @@ -0,0 +1,36 @@ +# PR-05:protocol action packet 写入退休 + +对应 [#4794](https://github.com/loopx-project/loopx/pull/4794)、 +[#4447](https://github.com/loopx-project/loopx/issues/4447)。 +[协议决定](../../../../reference/protocols/protocol-action-packet-decision-v0.md) +拥有迁移契约。首次包含 #4794 的正式发行版切换新写入,源码版本按相应代码行为 +运行;已经发布的 v1.1.0 产物不变。历史 v0 格式在 v0 reader 生命周期内继续支持。 +协议明确 bundled 消费者与 v1.1.0 回退范围;正常维护者评审/发布控制仍保留,本条 +不关闭 tracker。 + +- **交付变化。** 删除四个 quota 模块内六个现行写入点,以及失去调用者的 packet + builder/import。普通、暂停、required-read、capability-intent、host-recovery + 路径使用现有类型化契约。默认完整 decision 也不再输出旧字段,不只是 compact + view 省略。未新增 flag、schema 词表或决策 owner。 +- **保留责任。** Python Markdown 和 TS Effect/Envelope 的历史 reader、 + `protocol_action_packet_fields` 有序语义投影、summary 重建、opaque fallback、 + residue 和签名拒绝仍保留,不重写旧记录。实测 Python 字段迁移面由 5 降为 1, + TypeScript 仍为 2;同 diff 锚点保留这些兼容 reader,不虚报字段全仓消失。 +- **行为证据。** 八组完整新旧 quota payload 只去除 packet 后相等;规范签名文档 + 只去除 capsule 中对应见证后相等,摘要值可能因此变化。真实 CLI/重入/Envelope/ + live 测试 149 项通过。更名后的 `quota-without-legacy-packet-smoke.py` 会拒绝旧 + 默认输出;原 decision-note 文案 smoke 退役,行为由实际运行和兼容回归保护, + 文档结构继续由 docs governance 校验。 +- **版本化读回。** 实际 v1.1.0 源码 `607c11d75` 读取八组新输出和八组带 packet 的 + 基线样本,签名含义与 host admission 均通过。安装后的候选 wheel 验证普通/暂停 + 输出、打包 TS/JSON 资源、真实 bridge 与 host admission。这是有界合成证据, + 不代表完整历史存档或全部 host 资格已验证。 +- **持久历史证据。** 实际 v1.1.0 源码生成四组固定 decision/Envelope,保留原始 + 哈希。当前 reader 验证原签名,24 个篡改变体被拒绝。 + `scripts/verify_protocol_packet_migration.py` 要求准确且未修改的发行源码,复核 + 八组新输出与四组历史对象。真实 CLI 重放及 required-read/capability-intent + 组合测试验证身份、命令和读取顺序在缺少旧字段时保持正确。 +- **有界完成。** 默认 writer 已全部停止写入。协议已给出具体消费者/格式范围和 + 回滚步骤,不承诺私有客户端或完整历史存档兼容。依赖旧字段的外部客户端需先迁移 + 或保留 v1.1.0。未来移除 v0 reader 必须另行做破坏性迁移。相邻 quota PR 在 rebase + 时须保留这些不变量;其未合并改动不被悄悄计入本 PR 的 main 基线。 diff --git a/docs/archive/incidents/agent-scoped-user-gate-overreach-incident-20260624.md b/docs/archive/incidents/agent-scoped-user-gate-overreach-incident-20260624.md index c6a6ad5c10..4e95c47284 100644 --- a/docs/archive/incidents/agent-scoped-user-gate-overreach-incident-20260624.md +++ b/docs/archive/incidents/agent-scoped-user-gate-overreach-incident-20260624.md @@ -90,7 +90,8 @@ The durable repair landed in PR #629: - unscoped user gates remain global gates. - `examples/control_plane/work-lane-contract-smoke.py` - `examples/control_plane/quota-action-scope-guard-smoke.py` -- `examples/protocol/protocol-action-packet-smoke.py` +- `examples/protocol/quota-without-legacy-packet-smoke.py` (current successor to + the original `protocol-action-packet-smoke.py`) - `examples/control_plane/quota-plan-smoke.py` - a real active-state quota check confirmed the non-target agent returned `decision=run`, `requires_user_action=false`, and `delivery_allowed=true` diff --git a/docs/concepts/interaction-pattern-catalog.md b/docs/concepts/interaction-pattern-catalog.md index ad2b97a1e7..28e338f0a8 100644 --- a/docs/concepts/interaction-pattern-catalog.md +++ b/docs/concepts/interaction-pattern-catalog.md @@ -612,7 +612,7 @@ IP-003 scope metadata being ignored by the user-todo blocking summary. **Validation** - `regression/scoped-user-gate-fallback-contract.py` -- `examples/protocol/protocol-action-packet-smoke.py` +- `examples/protocol/quota-without-legacy-packet-smoke.py` - `examples/control_plane/work-lane-contract-smoke.py` - `examples/control_plane/quota-agent-scoped-user-gate-smoke.py` for `blocks_agent` scoped user gates that block only the target agent while preserving other-agent diff --git a/docs/development/control-plane-course/06-quota-decision-kernel.md b/docs/development/control-plane-course/06-quota-decision-kernel.md index ba7bcf24d7..7bac4b4c49 100644 --- a/docs/development/control-plane-course/06-quota-decision-kernel.md +++ b/docs/development/control-plane-course/06-quota-decision-kernel.md @@ -103,8 +103,7 @@ capability_gate work_lane_contract vision_continuation_audit user_todo_summary / agent_todo_summary -protocol_action_packet -compatibility fields +compatibility fields (historical protocol_action_packet is read-only) ``` 其中最稳定的执行入口是: diff --git a/docs/product/core-control-plane/rule-seam-map.md b/docs/product/core-control-plane/rule-seam-map.md index da8deb78fd..faab734f31 100644 --- a/docs/product/core-control-plane/rule-seam-map.md +++ b/docs/product/core-control-plane/rule-seam-map.md @@ -94,7 +94,7 @@ The next module-boundary PR should therefore: | Agent vision and goal routing contract | Future goal-route policy/CLI adapter plus `goal_vision_replan_contract_v0` | CLI-enforced bounded vision fields, per-agent vision checkpoints, and the vision/replan state machine. | Over-budget vision fails or compacts before status/quota; material closeouts emit `vision_checkpoint_v0`; quota consumes projection only and does not own per-agent vision storage. | | Quota plan and should-run assembly | `build_quota_plan`, `build_quota_should_run` | Thin orchestration layer that merges status, quota accounting, gates, and policy outputs. | `quota should-run` JSON field names and interaction contract stay compatible. | | Effect packet lens | `loopx.control_plane.effect_program.interpret_quota_should_run_packet` | Read-only canonical effect slots over the existing quota packet. | Runtime decisions unchanged; focused tests and docs consume the lens. | -| User/agent/CLI split | `_protocol_action_packet`, `_interaction_contract` | Protocol packet builder with no scheduler or writeback side effects. | Operator gate vs bounded delivery payloads keep the same action_required and must_attempt meanings. | +| User/agent/CLI split | `build_interaction_contract` | Structured user/agent/CLI projection with no scheduler or writeback side effects. | Operator gate vs bounded delivery payloads keep the same action_required and must_attempt meanings. | | Scheduler policy | `_scheduler_hint` wrapper plus `loopx.control_plane.scheduler.scheduler_hint` | Pure scheduler-hint builder fed by final decision state. | RRULE, reset token, and no-spend cadence fields stay stable for Codex App and local loops. | | Monitor writeback | `_quota_decision_due_monitor_item`, `build_quota_monitor_poll_event`, `record_quota_monitor_poll` | Monitor event/writeback module with idempotent todo lookup and next-due projection. | Due-monitor and external-evidence monitor-poll paths remain no-spend and reject non-monitor todos. | | Spend accounting | `build_quota_slot_spend_event`, `spend_quota_slot` | Quota accounting module with explicit accountable-run lookup. | Spend only after validated writeback; source enum and slot accounting remain unchanged. | diff --git a/docs/reference/effect-interpreter-packet.md b/docs/reference/effect-interpreter-packet.md index 4b409d0f2a..1a33eb5ba7 100644 --- a/docs/reference/effect-interpreter-packet.md +++ b/docs/reference/effect-interpreter-packet.md @@ -146,7 +146,17 @@ The decision is returned as: | `effective_action` | Machine-visible effective action | | `recommended_action` | Next concrete action text | | `action_portfolio` | Primary plus bounded typed fallbacks, when present | -| `protocol_action_packet.summary` | Compact actor-facing summary | +| `protocol_action_packet.summary` | Optional historical actor-facing summary; no action authority | + +For a source without `protocol_action_packet`, the quota lens exposes +`protocol_summary=null`; typed interaction, lane, and scheduler contracts still +supply obligations and next effects. Historical v0 and opaque summaries remain +readable observations and cannot override those contracts. The +[PR-05 migration](protocols/protocol-action-packet-decision-v0.md) +defines packet-free new quota/live/paused/recovery output from the first release +containing #4794. It names the supported bundled consumers, retained v0 format +lifetime and tested v1.1.0 rollback baseline; unknown external clients are not +automatically covered. `EffectTurn.observation.action_portfolio` is the canonical TypeScript-owned observation of this field. Python supplies only scope/capability-admitted todo diff --git a/docs/reference/protocols/protocol-action-packet-codex-cli-wrapper-v0.md b/docs/reference/protocols/protocol-action-packet-codex-cli-wrapper-v0.md index bf26b202c8..25b01d6617 100644 --- a/docs/reference/protocols/protocol-action-packet-codex-cli-wrapper-v0.md +++ b/docs/reference/protocols/protocol-action-packet-codex-cli-wrapper-v0.md @@ -4,8 +4,10 @@ This wrapper is the cold-path bridge between `protocol_action_packet_v0` and a future Codex CLI summarizer. It is intentionally outside `quota should-run` so the hot path keeps its interface budget. -The wrapper consumes a synthetic `protocol_router_comparison_v0` report and -builds a Codex CLI command envelope for an isolated project: +The wrapper consumes a synthetic `protocol_router_comparison_v0` report, not a +legacy field from fresh quota output. The [PR-05 migration](protocol-action-packet-decision-v0.md) +keeps it outside the live execution contract. It builds a Codex CLI command +envelope for an isolated project: ```text codex exec --skip-git-repo-check --ephemeral --ignore-user-config diff --git a/docs/reference/protocols/protocol-action-packet-decision-v0.md b/docs/reference/protocols/protocol-action-packet-decision-v0.md index 4705de530d..ec457099ac 100644 --- a/docs/reference/protocols/protocol-action-packet-decision-v0.md +++ b/docs/reference/protocols/protocol-action-packet-decision-v0.md @@ -1,65 +1,101 @@ -# Protocol Action Packet Decision v0 - -## Decision - -Keep `protocol_action_packet_v0` as the hot-path protocol simplification -contract for `quota should-run`. - -The opt-in TurnEnvelope may reconstruct this packet from its structured action -contracts and omit the repeated summary only when field-level parity succeeds; -the full decision continues to persist the compatibility packet. - -The hot path remains deterministic and rule-only: - -- `llm=no_api` -- primary actor: user or agent -- user-action requirement -- agent-action requirement -- quiet-noop allowance -- work lane -- compact action label - -Use the Codex CLI wrapper only as an explicit cold-path sidecar experiment. Do -not run it during routine quota/status/heartbeat routing. - -Defer direct LLM API wiring until a separate backend-comparison experiment -proves a measurable gain over deterministic labels on payload size, -user/agent-action clarity, and public-boundary safety. - -## Evidence - -The decision is based on four public-safe slices: - -1. `protocol_action_packet_v0` made `quota should-run` expose a compact - rule-only summary while preserving the detailed guard payload. -2. `protocol_router_comparison_v0` compared advancement, user-action, and - monitor-only scenarios and kept the minimum payload shrinkage above the - acceptance floor without model calls. -3. `protocol_action_packet_codex_cli_wrapper_v0` proved the Codex CLI command - envelope can be represented as a fake-contract smoke without invoking a - model. -4. The opt-in real Codex CLI probe ran once in an isolated fixture and produced - a compact sidecar while leaving the default smoke path fake/no-model. - -## Operating Rule - -`quota should-run`, dashboard status, and recurring heartbeat routing should -consume `protocol_action_packet_v0` directly. They should not call Codex CLI, -direct LLM APIs, runner adapters, Docker/cloud environments, or paid compute. - -Cold-path experiments may call Codex CLI only when the command is explicit, -isolated, ephemeral, and sidecar-only. The sidecar may record a final compact -summary, prompt length, return code, and stdout/stderr character counts; it -must not persist raw stderr, raw session history, private traces, credentials, -or local auth material. - -## Next Work - -The protocol simplification spike is complete enough for the current meta -lane. Future work should move back to the long-horizon benchmark program unless -a concrete protocol regression appears. - -The next benchmark-side step is the approved Terminal-Bench/Harbor execution -environment readiness lane: check whether local Docker or an approved cloud -execution environment is available, then attempt a single-task no-submit Harbor -Codex pilot only after the environment and benchmark rules are clear. +# Protocol Action Packet Migration Contract + +## Release Boundary + +[PR #4794](https://github.com/loopx-project/loopx/pull/4794) retires +`protocol_action_packet` from freshly constructed quota decisions. The cutover +is the first official LoopX release whose source contains this change; source +and prerelease builds containing it use the same behavior. Published v1.1.0 +artifacts are not changed or silently backported. The release tag identifies +that source boundary; this PR does not create a release or choose its date. + +The change applies to normal quota, paused quota, live required-read and +capability-intent projection, and unsettled-host recovery, including full JSON +and Markdown output. It is independent of the opt-in TurnEnvelope view. There +is no new runtime flag, replacement summary field, or second decision owner. +The existing Envelope and signature schema identifiers stay at v0: the packet +was already optional to their readers. New signatures omit its capsule witness; +they need not equal signatures of older packet-bearing outputs. + +## Supported Consumers and Upgrade Order + +The supported consumer set for this migration is explicit: + +| Consumer | New output | Stored v0 input | +| --- | --- | --- | +| Bundled quota CLI and Markdown renderer | Read `interaction_contract`, `work_lane_contract`, `scheduler_hint` and exact CLI actions; no legacy summary line | Continue displaying the packet when a stored decision contains it | +| TypeScript Effect reader and Python bridge | `protocol_summary=null`; typed obligations and next effects remain authoritative | Keep the supplied summary as an observation, never as execution authority | +| Python/TypeScript TurnEnvelope builders/readers | No `contract_capsule.protocol_action_packet`; other signed dimensions remain | Preserve verified reconstruction, residue and opaque-summary fallback | +| Bundled host authority extraction | Verify the canonical document and hashes before accepting the action | Verify the stored document and hashes without rebuilding its source | + +Upgrade readers before changing producers. LoopX v1.1.0 is the tested rollback +reader baseline and already accepts missing packets. A client that requires +this field or parses its summary must migrate to the typed contracts above +before adopting the new producer, or remain on v1.1.0 until adapted. Such a +client is not automatically covered by this migration's compatibility claim. +No compatibility promise is made for unidentified external clients, changed +private forks, or older executable releases that have not been qualified. + +## Historical Format Support + +Support is bounded by format rather than record age: valid +`protocol_action_packet_v0` observations and their existing v0 Envelope/capsule +representation remain readable while the v0 reader contract is supported. +There is no scheduled removal of these readers in this migration. A future +breaking protocol revision must separately declare its reader-removal and data +migration policy; this writer removal cannot authorize it. Storage retention +policies and execution-time identity/permission checks remain independent. + +Keep `protocol_action_packet_fields()`, ordered summary reconstruction, +`verified_with_residue`, and `unverified_retain_summary`. Do not rewrite stored +records, regenerate their signatures, or replace an opaque historical summary +with today's action text. Missing/mismatched signatures and changed signed +fields still fail host admission. Successful historical readback does not +re-authorize a stale task or bypass fresh receipt, lease or capability checks. + +## Qualification and Rollback + +The committed historical fixture is produced by actual v1.1.0 source +`607c11d75`, with synthetic public-safe input. Its stored envelopes and signature +hashes are immutable test inputs: current readers must accept valid examples +and reject tampering without regenerating the expected signature. The ordinary +compatibility fixtures additionally cover absent, v0, opaque and residue forms, +quiet waits and user gates. These examples qualify the specified formats and +readers, not an unknown production archive. + +The repeatable version check exercises eight fresh packet-free decisions +(normal, paused, operator gate and exhausted, each ordinary/settled) plus the +four frozen release records through actual v1.1.0 reader/host code. It checks +canonical source/envelope agreement, host admission and unchanged input records: + +```bash +# Prepare this pinned checkout and its Node dependencies once; no live state is used. +git worktree add --detach ../loopx-v1.1.0 v1.1.0 +npm ci --prefix ../loopx-v1.1.0 --ignore-scripts +uv run --extra test python scripts/verify_protocol_packet_migration.py --reader-checkout ../loopx-v1.1.0 +uv run --extra test python -m pytest tests/control_plane/test_protocol_packet_history.py tests/control_plane/test_protocol_packet_retirement_cli.py +``` + +The verifier rejects a different or modified reader revision; it does not fetch +or install anything itself. Source/envelope canonical documents and host +admission must agree. +An installed wheel is checked for packaged TS/JSON resources, the real bridge, +normal/paused output and signed host admission. Real CLI replay and the combined +required-read/capability-intent path are regression tests in this PR. + +For rollback, stop starting new host turns, retain all runtime records unchanged, +and switch producer and reader together to the tagged v1.1.0 baseline. That +producer emits the old packet again; the old reader continues to accept the +packet-free records covered above. This is rollback of this field migration, +not certification that v1.1.0 can undo unrelated protocol or storage changes. +Reverting the writer-removal commit is also possible after resolving intervening +code changes; it must not rewrite stored signatures or receipts. + +## Operating Boundary + +Routine quota/status/heartbeat routing stays deterministic and makes no model +or external-provider call to replace the summary. Cold summarizer experiments +remain explicit, isolated and sidecar-only; they must not persist raw stderr, +private session traces or credentials. No other legacy field is retired by +this contract. PR approval accepts this scoped migration; normal maintainer +merge/release controls remain, and #4447 has its own remaining obligations. diff --git a/docs/reference/protocols/protocol-action-packet-router-comparison-v0.md b/docs/reference/protocols/protocol-action-packet-router-comparison-v0.md index 80cfb83535..322d036836 100644 --- a/docs/reference/protocols/protocol-action-packet-router-comparison-v0.md +++ b/docs/reference/protocols/protocol-action-packet-router-comparison-v0.md @@ -1,8 +1,10 @@ # Protocol Action Packet Router Comparison v0 -`protocol_action_packet_v0` is the rule-only hot-path baseline for executor -action clarity. It intentionally stays small: `schema_version` plus one compact -`summary` string inside `quota should-run`. +`protocol_action_packet_v0` was the rule-only hot-path baseline for executor +action clarity. Under the [PR-05 migration](protocol-action-packet-decision-v0.md), +new `quota should-run` output uses structured contracts and omits this packet. +This comparison retains explicitly synthetic legacy-format input; it neither +requires current quota to emit the packet nor measures current quota shrinkage. The next experiment must run off the hot path. A comparison record uses schema `protocol_router_comparison_v0` and checks whether a Codex CLI or optional diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 206de32e5f..039183f673 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -11,7 +11,12 @@ Preview it explicitly: loopx quota should-run --goal-id --agent-id --turn-envelope ``` -The default `quota should-run` output remains unchanged. The v0 envelope keeps: +The envelope flag selects a projection of the full decision. The original v0 +contract left the default `quota should-run` output unchanged; the +[PR-05 migration](protocol-action-packet-decision-v0.md) omits +`protocol_action_packet` from new full decisions, including live, paused and +recovery output, from the first release containing #4794. Historical v0 reads +remain supported for the v0 reader lifetime. The v0 envelope keeps: - the selected todo, claim, and effective action; - the bounded action portfolio when the agent must choose among multiple @@ -144,15 +149,26 @@ The compact envelope does not truncate those executable commands into unusable strings. It carries non-exhaustive `writeback.suggested_todo_ids` plus `selection_command_ref`; the full decision remains the authority for exact argv. -`protocol_action_packet` remains in the full decision/cold path. The envelope -reconstructs its ordered semantic fields from `action`, `user`, work-lane, -automation, and scheduler contracts, while carrying the explicit -`llm_policy=no_api` invariant. When the reconstruction matches exactly, the -capsule keeps only the source summary hash and derivation status. If a compact -action differs, it keeps only that field-level `residue`; if an older or opaque -packet cannot be reconstructed, it retains the original summary. This removes -repetition only after parity and does not change source packet persistence or -the default quota output. +Historical full decisions may carry `protocol_action_packet`. For those inputs, +the envelope reconstructs its ordered semantic fields from `action`, `user`, +work-lane, automation, and scheduler contracts, while carrying the explicit +`llm_policy=no_api` invariant. Exact reconstruction retains the source summary +hash and derivation status; a differing compact action retains field-level +`residue`; an opaque summary follows `unverified_retain_summary`. These remain +historical read paths and do not rewrite stored packets or envelopes. + +Under PR-05, a new source without a packet produces no +`contract_capsule.protocol_action_packet` witness. The ordered semantic +projection `protocol_action_packet_fields` and historical summary renderer +remain; packet absence does not remove typed obligations or their signature +coverage. Source and envelope signature documents must match for that input. +Compared with a packet-bearing source, the document may lack the capsule's +packet witness and have a different hash. This is not a cross-version hash +compatibility promise; existing signature checks and historical signatures +remain intact. The v1.1.0 reader/host accepts the tested new and stored v0 examples. +The [migration contract](protocol-action-packet-decision-v0.md) defines the +release boundary, v0 reader lifetime, consumer set and rollback steps; unknown +external readers and complete private archives are not implicitly qualified. Large todo summaries, frontier diagnostics, readiness history, compatibility fields, and warning collections stay on the referenced full-decision/status @@ -227,8 +243,9 @@ monitor, user-gate, capability-gate, workspace-guard, and blocked states. promotion fixture. It covers delivery, monitor quiet-skip, user gate, capability gate, workspace guard, autonomous replan, successor replan, blocked, and throttled decisions. Every case must preserve the canonical action -signature, reconstruct `protocol_action_packet`, and remain within the 8 KiB -budget. +signature and remain within the 8 KiB budget. Historical packet-bearing inputs +must retain their reconstruction, residue, or opaque-summary witness; inputs +without a packet must preserve typed obligations without inventing a witness. The matrix records exact measurements in validation rather than treating a dated size range as the contract. This keeps the projection available as an diff --git a/docs/state-interaction-model.md b/docs/state-interaction-model.md index a357ed60e0..6ccdf3e1d6 100644 --- a/docs/state-interaction-model.md +++ b/docs/state-interaction-model.md @@ -166,8 +166,17 @@ loopx --format json quota should-run --goal-id The guard's `interaction_contract` is the first-class protocol. Older fields such as `execution_obligation`, `heartbeat_recommendation`, -`work_lane_contract`, `external_evidence_observation`, `goal_boundary`, and -`protocol_action_packet` remain compatibility and drill-down fields. +`work_lane_contract`, `external_evidence_observation`, and `goal_boundary` +remain compatibility and drill-down fields. Historical `protocol_action_packet` +summaries are read-only observations. The +[PR-05 migration](reference/protocols/protocol-action-packet-decision-v0.md) +omits the packet from new quota/live/paused/recovery outputs while +retaining historical packet, opaque/residue, and signature reads without +rewriting records. Current consumers use typed contracts; a missing packet +neither relaxes an obligation nor grants authority. The first official release containing #4794 changes new writes; historical v0 +formats remain supported for the v0 reader lifetime. The bundled reader set and +v1.1.0 rollback scope are explicit in that migration contract; external clients +requiring the old summary must migrate before upgrading. Executors should treat `interaction_contract.agent_channel.primary_action` as the single action entrypoint for the current turn. If it carries a `resolution_trace`, that trace is only a compact explanation of which projected diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index 549886e174..f0a6913e75 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -1525,9 +1525,10 @@ carry any writeback review guidance. The trace is not an independent next-action authority and does not imply automatic active-state writeback. `execution_obligation`, `heartbeat_recommendation`, `work_lane_contract`, -`external_evidence_observation`, `goal_boundary`, and -`protocol_action_packet` remain compatibility and drill-down fields under that -contract, not competing sources of truth. +`external_evidence_observation`, and `goal_boundary` remain compatibility and +drill-down fields under that contract, not competing sources of truth. +`protocol_action_packet` belongs to the historical summary contract; the +PR-05 migration below omits it from new outputs. The same payload includes `scheduler_hint.schema_version=scheduler_hint_v0`. This is the scheduling contract for host runtimes, not a delivery permission: Codex App can back off its automation cadence for long waits, while Codex CLI @@ -1591,23 +1592,25 @@ requires `execution_obligation.must_attempt_work=false` and no blocker-push notification such as `notify_user_on_open_todo=true`; when both are present, notify the user and do not spend. Verified `mapped_noop_if_unchanged` remains a quiet no-op case. -The guard also emits `protocol_action_packet.schema_version = -protocol_action_packet_v0`, a compact rule-only packet for executor and future -LLM-router experiments. It distills the same quota guard into one primary actor, -user/agent action requirement, quiet-noop allowance, execution lane, and a short -`llm=no_api` marker inside a single `summary` string so the hot path stays -within interface budget. The detailed spend policy remains in -`heartbeat_recommendation.spend_policy`. This packet is not a new source of -authority and does not authorize model/API use; it is the deterministic baseline -that an optional Codex/LLM summarizer must beat on payload shrinkage and -user/agent action clarity before direct LLM API wiring is added. When an open -todo uses the common `[P*] short title: details` shape, the packet uses the -short title as the action label so long progress notes do not re-enter the hot -path. -If open user todos coexist with executable agent work, the packet keeps the -primary actor as `agent` but adds `user_action_pending=true` plus a compact -`user_action` label. This preserves the owner-visible blocker without -mislabeling that owner todo as `agent_action`. +Historical guard outputs carried `protocol_action_packet_v0`, a deterministic +summary of actor, action requirements, quiet-noop allowance, and lane with +`llm=no_api`. It conveyed no independent execution or model/API authority. +The [PR-05 migration](reference/protocols/protocol-action-packet-decision-v0.md) +omits `protocol_action_packet` from all new quota/live/paused/recovery +outputs, including full-decision cold reads. Current executors and status/display +consumers should read the typed interaction, lane, and scheduler contracts above; +packet absence must not imply permission to deliver, spend, or stay quiet. +Historical packet, opaque-summary, residue, and signature readers remain; +stored records are not rewritten. New source/envelope signature documents may +omit the capsule's packet witness while preserving semantic fields: equal hashes +with older packet-bearing outputs are not promised. The release boundary is the first official release containing #4794; +published v1.1.0 artifacts remain unchanged. Valid v0 historical formats stay +supported for the lifetime of the v0 reader contract, without a removal date +introduced by this migration. The named bundled consumers and v1.1.0 rollback +baseline are qualified; external clients requiring this optional packet must +migrate or pin the previous release. See the migration contract for exact scope +and rollback steps. + When a registry-enabled goal has `control_plane.self_repair.enabled=true`, `quota should-run` may return `decision=self_repair`, `self_repair_allowed=true`, `stall_self_repair`, and an `effective_action` such diff --git a/examples/capability-gate-smoke.py b/examples/capability-gate-smoke.py index 124e9890a5..ba4178d1ab 100644 --- a/examples/capability-gate-smoke.py +++ b/examples/capability-gate-smoke.py @@ -187,7 +187,8 @@ def main() -> int: "primary_blocked_todo_id" ] == "todo_capability_1", p0_fallback assert p0_fallback["recommended_action"] == p0_validate["text"], p0_fallback - assert "choose one of 2 capability-runnable todo(s)" in p0_fallback["protocol_action_packet"]["summary"], p0_fallback + assert "choose one of 2 capability-runnable todo(s)" in p0_fallback["interaction_contract"]["agent_channel"]["primary_action"], p0_fallback + assert "protocol_action_packet" not in p0_fallback, p0_fallback fallback = build_quota_should_run( status_payload([p0_benchmark, p0_network, p1_gpu, p1_docs]), @@ -210,7 +211,8 @@ def main() -> int: assert fallback["capability_gate"]["blocked_candidates"][1]["missing_capabilities"] == ["network"], fallback assert fallback["capability_gate"]["blocked_candidates"][2]["missing_capabilities"] == ["gpu_runner"], fallback assert fallback["recommended_action"] == p1_docs["text"], fallback - assert "choose one of 1 capability-runnable todo(s)" in fallback["protocol_action_packet"]["summary"], fallback + assert "choose one of 1 capability-runnable todo(s)" in fallback["interaction_contract"]["agent_channel"]["primary_action"], fallback + assert "protocol_action_packet" not in fallback, fallback repair_candidate = build_quota_should_run( status_payload([p0_repair_benchmark_bridge, p0_benchmark, p1_docs]), diff --git a/examples/control_plane/agent-monitor-only-work-mode-smoke.py b/examples/control_plane/agent-monitor-only-work-mode-smoke.py index 60cffbb3d0..756f7f7c86 100644 --- a/examples/control_plane/agent-monitor-only-work-mode-smoke.py +++ b/examples/control_plane/agent-monitor-only-work-mode-smoke.py @@ -135,7 +135,8 @@ def main() -> None: ), due assert "autonomous_replan_obligation" not in due, due assert due["selected_todo"]["todo_id"] == "todo_due_monitor", due - assert "todo_advancement" not in due["protocol_action_packet"]["summary"], due + assert "todo_advancement" not in due["interaction_contract"]["agent_channel"]["primary_action"], due + assert "protocol_action_packet" not in due, due peer = decision_for( [ diff --git a/examples/control_plane/heartbeat-quota-flow-smoke.py b/examples/control_plane/heartbeat-quota-flow-smoke.py index 906b429bed..f6577b1dba 100644 --- a/examples/control_plane/heartbeat-quota-flow-smoke.py +++ b/examples/control_plane/heartbeat-quota-flow-smoke.py @@ -739,7 +739,7 @@ def main() -> int: interaction = follow_up["interaction_contract"] assert interaction["agent_channel"]["primary_action"] == EXPECTED_AGENT_TODO_ACTION, interaction assert "state_action_projection_warning" not in follow_up, follow_up - assert "agent_action=" + EXPECTED_AGENT_TODO_ACTION in follow_up["protocol_action_packet"]["summary"], follow_up + assert "protocol_action_packet" not in follow_up, follow_up assert registry_path.read_text(encoding="utf-8") == registry_before with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-operator-gate-") as tmp: @@ -812,7 +812,8 @@ def main() -> int: assert frontier["monitor_only_lanes"]["present"] is True, frontier assert frontier["monitor_only_lanes"]["quiet_until_material_transition"] is True, frontier assert frontier["replan_required"] is False, frontier - assert "automation=keep_active_quiet" in first_guard["protocol_action_packet"]["summary"], first_guard + assert first_guard["automation_liveness"]["automation_action"] == "keep_active_quiet", first_guard + assert "protocol_action_packet" not in first_guard, first_guard assert count_events(runtime, "quota_monitor_poll") == 0, first_guard interaction = first_guard["interaction_contract"] assert interaction["mode"] == "monitor_quiet_skip", interaction diff --git a/examples/control_plane/interaction-contract-state-machine-smoke.py b/examples/control_plane/interaction-contract-state-machine-smoke.py index 1914efd438..b4fc695a97 100644 --- a/examples/control_plane/interaction-contract-state-machine-smoke.py +++ b/examples/control_plane/interaction-contract-state-machine-smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Canary the interaction/protocol state machine across major quota modes.""" +"""Canary the structured interaction state machine across major quota modes.""" from __future__ import annotations @@ -24,7 +24,6 @@ ) from loopx.control_plane.work_items.interaction_contract import ( # noqa: E402 build_interaction_contract, - build_protocol_action_packet, user_channel_action_required, ) from loopx.control_plane.work_items.work_lane import build_work_lane_contract # noqa: E402 @@ -195,7 +194,6 @@ def finalize(payload: dict[str, Any]) -> dict[str, Any]: ], scheduler_execution_context=APP_SCHEDULER_CONTEXT, ) - payload["protocol_action_packet"] = build_protocol_action_packet(payload) return payload @@ -276,7 +274,7 @@ def _assert_cross_layer_case( must_attempt: bool = True, quiet: bool = False, spend: bool = True, - summary_fragments: tuple[str, ...] = (), + action_fragment: str | None = None, cli_fragments: tuple[str, ...] = (), ) -> None: contract = payload["interaction_contract"] @@ -297,9 +295,9 @@ def _assert_cross_layer_case( name, scheduler_hint, ) - summary = payload["protocol_action_packet"]["summary"] - for fragment in summary_fragments: - assert fragment in summary, (name, summary) + assert "protocol_action_packet" not in payload, (name, payload) + if action_fragment is not None: + assert action_fragment in contract["agent_channel"]["primary_action"], (name, contract) cli_actions = " ".join(contract["cli_channel"]["next_cli_actions"]) for fragment in cli_fragments: assert fragment in cli_actions, (name, cli_actions) @@ -319,7 +317,6 @@ def assert_cross_layer_state_machine_matrix() -> None: lane="advancement_task", obligation="advance_one_bounded_segment", interaction="bounded_delivery", - summary_fragments=("lane=advancement_task", "scheduler=run_now"), ) _assert_cross_layer_case( "due monitor preemption", @@ -327,7 +324,7 @@ def assert_cross_layer_state_machine_matrix() -> None: lane="continuous_monitor", obligation="attempt_due_monitor", interaction="bounded_delivery", - summary_fragments=("lane=continuous_monitor", "todo_due_monitor"), + action_fragment="todo_due_monitor", ) _assert_cross_layer_case( "monitor schedule gap repair", @@ -335,10 +332,7 @@ def assert_cross_layer_state_machine_matrix() -> None: lane="advancement_task", obligation="repair_monitor_schedule_metadata", interaction="bounded_delivery", - summary_fragments=( - "lane=advancement_task", - "repair the selected continuous_monitor todo", - ), + action_fragment="repair the selected continuous_monitor todo", ) _assert_cross_layer_case( "monitor quiet wait", @@ -358,10 +352,6 @@ def assert_cross_layer_state_machine_matrix() -> None: must_attempt=False, quiet=True, spend=False, - summary_fragments=( - "lane=continuous_monitor", - "scheduler=backoff_until_material_transition", - ), ) _assert_cross_layer_case( "agent scope wait", @@ -379,13 +369,11 @@ def assert_cross_layer_state_machine_matrix() -> None: must_attempt=False, quiet=True, spend=False, - summary_fragments=("scheduler=backoff_until_reassigned",), ) _assert_cross_layer_case( "successor replan", _successor_replan_payload(), interaction=AgentScopeFrontierAction.SUCCESSOR_REPLAN_REQUIRED.value, - summary_fragments=("scheduler=run_now",), cli_fragments=("todo_deferred_successor",), ) @@ -405,10 +393,9 @@ def assert_bounded_delivery_bundle() -> None: assert contract["agent_channel"]["quiet_noop_allowed"] is False, contract assert contract["cli_channel"]["spend_after_validation"] is True, contract assert payload["scheduler_hint"]["action"] == "run_now", payload - summary = payload["protocol_action_packet"]["summary"] - assert "actor=agent" in summary, summary - assert "lane=advancement_task" in summary, summary - assert "scheduler=run_now" in summary, summary + assert contract["user_channel"]["action_required"] is False, contract + assert payload["work_lane_contract"]["lane"] == "advancement_task", payload + assert "protocol_action_packet" not in payload, payload def assert_user_notice_can_coexist_with_bounded_delivery() -> None: @@ -474,10 +461,7 @@ def assert_user_action_is_non_blocking_notice() -> None: ], contract assert contract["agent_channel"]["must_attempt"] is True, contract assert contract["agent_channel"]["delivery_allowed"] is True, contract - summary = payload["protocol_action_packet"]["summary"] - assert "actor=agent" in summary, summary - assert "user_action_required=false" in summary, summary - assert "user_action_pending=true" in summary, summary + assert "protocol_action_packet" not in payload, payload def assert_gate_cooldown_suppresses_non_blocking_notice() -> None: diff --git a/examples/control_plane/quota-agent-scoped-user-gate-smoke.py b/examples/control_plane/quota-agent-scoped-user-gate-smoke.py index fd21cdf41b..53525bd1c5 100644 --- a/examples/control_plane/quota-agent-scoped-user-gate-smoke.py +++ b/examples/control_plane/quota-agent-scoped-user-gate-smoke.py @@ -730,7 +730,7 @@ def assert_exact_todo_gate_only_blocks_target_todo() -> None: assert lane_action["source"] == "scoped_user_gate_fallback.selected_executable", lane_action assert lane_action["selected_by"] == "scoped_user_gate_fallback", lane_action assert lane_action["replaces_gated_goal_next_action"] is True, lane_action - assert "todo_benchmark_ledger_cleanup" in payload["protocol_action_packet"]["summary"], payload + assert "protocol_action_packet" not in payload, payload monitor_ids = { item["todo_id"] for item in payload["agent_todo_summary"]["first_open_items"] @@ -1032,7 +1032,8 @@ def assert_agent_without_advancement_candidate_and_only_monitor_work_stays_quiet assert "profile_signature" not in reset, reset assert "reset_condition_summary" not in reset, reset assert "no_spend_for_reset" not in reset, reset - assert "scheduler=backoff_until_material_transition" in payload["protocol_action_packet"]["summary"], payload + assert payload["scheduler_hint"]["action"] == "backoff_until_material_transition", payload + assert "protocol_action_packet" not in payload, payload def main() -> int: diff --git a/examples/control_plane/quota-plan-smoke.py b/examples/control_plane/quota-plan-smoke.py index c61a173d69..521b5c7cfc 100644 --- a/examples/control_plane/quota-plan-smoke.py +++ b/examples/control_plane/quota-plan-smoke.py @@ -524,7 +524,7 @@ def assert_outcome_floor_projected_blocker_quiet_noop() -> None: assert monitor_contract["agent_channel"]["must_attempt"] is True, monitor_contract assert monitor_contract["agent_channel"]["quiet_noop_allowed"] is False, monitor_contract assert "outcome-floor evidence" in monitor_contract["agent_channel"]["primary_action"], monitor_contract - assert "outcome-floor evidence" in monitor_decision["protocol_action_packet"]["summary"], monitor_decision + assert "protocol_action_packet" not in monitor_decision, monitor_decision def assert_control_plane_health_self_repair_should_run() -> None: diff --git a/examples/control_plane/work-lane-contract-smoke.py b/examples/control_plane/work-lane-contract-smoke.py index f9e67324c1..21a3c51499 100644 --- a/examples/control_plane/work-lane-contract-smoke.py +++ b/examples/control_plane/work-lane-contract-smoke.py @@ -283,12 +283,11 @@ def assert_monitor_only_with_user_todo_surfaces_user_action_without_transition() assert interaction["user_channel"]["notify"] == "NOTIFY", interaction assert interaction["agent_channel"]["must_attempt"] is True, interaction assert interaction["agent_channel"]["quiet_noop_allowed"] is False, interaction - packet = guard["protocol_action_packet"] - assert "actor=agent_with_user_gate" in packet["summary"], packet - assert "user_action_required=true" in packet["summary"], packet - assert "agent_action_required=true" in packet["summary"], packet - assert "quiet_noop_allowed=false" in packet["summary"], packet - assert "user_action=[P1] Decide whether to approve a no-submit Terminal-Bench" in packet["summary"], packet + assert len(interaction["user_channel"]["actions"]) == 1, interaction + assert interaction["user_channel"]["actions"][0].startswith( + "[P1] Decide whether to approve a no-submit Terminal-Bench" + ), interaction + assert "protocol_action_packet" not in guard, guard markdown = render_quota_should_run_markdown(guard) assert "obligation=repair_monitor_schedule_metadata" in markdown, markdown assert "work_lane_monitor_policy: repair_schedule_metadata_before_quiet_wait" in markdown, markdown @@ -666,7 +665,7 @@ def assert_mixed_monitor_and_advancement_routes_to_advancement() -> None: assert lane["must_attempt_work"] is True, lane assert guard["recommended_action"] == executable_todo, guard assert guard["interaction_contract"]["agent_channel"]["primary_action"] == executable_todo, guard - assert f"agent_action={executable_todo}" in guard["protocol_action_packet"]["summary"], guard + assert "protocol_action_packet" not in guard, guard first_items = guard["agent_todo_summary"]["first_open_items"] assert [item["task_class"] for item in first_items] == ["advancement_task", "continuous_monitor"], guard @@ -907,9 +906,8 @@ def assert_external_monitor_context_recommends_executable_backlog() -> None: assert guard["interaction_contract"]["agent_channel"]["primary_action"] == ( "[P1] Behavior regression suite lane" ), guard - packet = guard["protocol_action_packet"]["summary"] - assert "lane=advancement_task" in packet, packet - assert "agent_action=[P1] Behavior regression suite lane" in packet, packet + assert guard["work_lane_contract"]["lane"] == "advancement_task", guard + assert "protocol_action_packet" not in guard, guard def assert_benchmark_readiness_scan_routes_to_advancement() -> None: diff --git a/examples/fixtures/pr-review.public.json b/examples/fixtures/pr-review.public.json index a7be24af37..ef9a4b104d 100644 --- a/examples/fixtures/pr-review.public.json +++ b/examples/fixtures/pr-review.public.json @@ -113,7 +113,7 @@ "deletions": 8, "files": [ {"path": "src/gate_prompt.py", "additions": 42, "deletions": 6}, - {"path": "examples/protocol/protocol-action-packet-smoke.py", "additions": 18, "deletions": 2} + {"path": "examples/protocol/quota-without-legacy-packet-smoke.py", "additions": 18, "deletions": 2} ], "commits": [ {"messageHeadline": "runtime: align gate prompt with scoped todos", "authoredDate": "2026-06-27T09:00:00Z", "committedDate": "2026-06-27T09:00:00Z"} diff --git a/examples/maintenance-latest-run-routing-smoke.py b/examples/maintenance-latest-run-routing-smoke.py index 21af9de76b..0497fcdd3d 100644 --- a/examples/maintenance-latest-run-routing-smoke.py +++ b/examples/maintenance-latest-run-routing-smoke.py @@ -122,10 +122,11 @@ def main() -> int: assert decision["state"] == "eligible", decision assert decision["status"] == "state_refreshed", decision assert decision["recommended_action"] == AGENT_TODO, decision - protocol_summary = decision["protocol_action_packet"]["summary"] - assert READINESS_ACTION not in protocol_summary, decision - assert WORK_ACTION not in protocol_summary, decision - assert "agent_action=[P1] Benchmark e2e-first evidence lane" in protocol_summary, decision + primary_action = decision["interaction_contract"]["agent_channel"]["primary_action"] + assert READINESS_ACTION not in primary_action, decision + assert WORK_ACTION not in primary_action, decision + assert primary_action == "[P1] Benchmark e2e-first evidence lane", decision + assert "protocol_action_packet" not in decision, decision assert "promotion_readiness_warning" not in decision, decision print("maintenance-latest-run-routing-smoke ok") diff --git a/examples/protocol/protocol-action-packet-decision-smoke.py b/examples/protocol/protocol-action-packet-decision-smoke.py deleted file mode 100644 index 5565651622..0000000000 --- a/examples/protocol/protocol-action-packet-decision-smoke.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke-test the protocol action packet decision note.""" - -from __future__ import annotations - -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -DECISION_DOC = REPO_ROOT / "docs" / "reference" / "protocols" / "protocol-action-packet-decision-v0.md" -WRAPPER_SMOKE = REPO_ROOT / "examples" / "protocol" / "protocol-action-packet-codex-cli-wrapper-smoke.py" - - -def main() -> None: - text = DECISION_DOC.read_text(encoding="utf-8") - wrapper_smoke = WRAPPER_SMOKE.read_text(encoding="utf-8") - - required = [ - "Keep `protocol_action_packet_v0` as the hot-path protocol simplification", - "`llm=no_api`", - "Use the Codex CLI wrapper only as an explicit cold-path sidecar experiment.", - "Defer direct LLM API wiring", - "should not call Codex CLI", - "default smoke path fake/no-model", - "must not persist raw stderr", - "Terminal-Bench/Harbor execution", - "environment readiness lane", - ] - for needle in required: - assert needle in text, needle - - assert "--real-codex-cli" in wrapper_smoke - assert "real_codex_cli_probe" in wrapper_smoke - assert "fake_codex_cli_contract" in wrapper_smoke - assert text.index("## Decision") < text.index("## Evidence") < text.index("## Operating Rule") - assert text.index("## Operating Rule") < text.index("## Next Work") - print("protocol-action-packet-decision-smoke ok") - - -if __name__ == "__main__": - main() diff --git a/examples/protocol/protocol-action-packet-router-comparison-smoke.py b/examples/protocol/protocol-action-packet-router-comparison-smoke.py index d37ac875c7..5aa15fd210 100644 --- a/examples/protocol/protocol-action-packet-router-comparison-smoke.py +++ b/examples/protocol/protocol-action-packet-router-comparison-smoke.py @@ -4,158 +4,29 @@ from __future__ import annotations import json -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(REPO_ROOT)) - -from loopx.quota import build_quota_should_run - - -GOAL_ID = "protocol-router-comparison-fixture" SCHEMA_VERSION = "protocol_router_comparison_v0" - -def todo(index: int, text: str, *, role: str = "agent", priority: str = "P1", task_class: str | None = None) -> dict: - item = { - "index": index, - "text": text, - "role": role, - "status": "open", - "priority": priority, - } - if task_class: - item["task_class"] = task_class - return item - - -def status_payload( - *, - scenario_id: str, - agent_todos: list[dict], - user_todos: list[dict] | None = None, - status: str = "protocol_router_comparison_fixture", - next_action: str | None = None, -) -> dict: - if next_action is None: - next_action = f"Run cold-path comparison fixture for {scenario_id}." - agent_summary = { - "schema_version": "todo_summary_v0", - "source_section": "Agent Todo", - "total_count": len(agent_todos), - "open_count": len(agent_todos), - "done_count": 0, - "first_open_items": agent_todos[:3], - } - item = { - "goal_id": GOAL_ID, - "status": status, - "waiting_on": "codex", - "severity": "info", - "source": "project_asset", - "recommended_action": next_action, - "quota": { - "compute": 1.0, - "window_hours": 24, - "slot_minutes": 1, - "allowed_slots": 10, - "spent_slots": 0, - "state": "eligible", - "reason": "eligible fixture", - }, - "project_asset": { - "next_action": next_action, - "stop_condition": "stop on private material", - "agent_todos": agent_summary, - }, - } - if user_todos: - user_summary = { - "schema_version": "todo_summary_v0", - "source_section": "User Todo / Owner Review Reading Queue", - "total_count": len(user_todos), - "open_count": len(user_todos), - "done_count": 0, - "first_open_items": user_todos[:3], - "items": user_todos, - } - item["user_todos"] = user_summary - item["project_asset"]["user_todos"] = user_summary - payload = { - "ok": True, - "attention_queue": {"items": [item]}, - "run_history": { - "goals": [ - { - "id": GOAL_ID, - "registry_member": True, - "status": status, - "adapter_kind": "harness_self_improvement", - "adapter_status": "connected-read-only", - "quota": { - "compute": 1.0, - "window_hours": 24, - "slot_minutes": 1, - "allowed_slots": 10, - }, - } - ] - }, - } - advancement_items = [ - item - for item in agent_todos - if item.get("task_class") == "advancement_task" - or "protocol simplification" in str(item.get("text") or "") - ] - monitor_items = [ - item - for item in agent_todos - if item.get("task_class") == "continuous_monitor" - or "observation lane" in str(item.get("text") or "") - ] - if advancement_items: - payload["attention_queue"]["autonomous_backlog_candidates"] = { - "source": "attention_queue.agent_todos", - "open_count": len(advancement_items), - "task_class": "advancement_task", - "items": [ - { - "goal_id": GOAL_ID, - "status": status, - "waiting_on": "codex", - "quota_state": "eligible", - "priority": item.get("priority"), - "todo_index": item.get("index"), - "task_class": "advancement_task", - "text": item.get("text"), - "source": "agent_todos", - } - for item in advancement_items - ], - } - if monitor_items: - payload["attention_queue"]["autonomous_monitor_candidates"] = { - "source": "attention_queue.agent_todos", - "open_count": len(monitor_items), - "task_class": "continuous_monitor", - "items": [ - { - "goal_id": GOAL_ID, - "status": status, - "waiting_on": "codex", - "quota_state": "eligible", - "priority": item.get("priority"), - "todo_index": item.get("index"), - "task_class": "continuous_monitor", - "text": item.get("text"), - "source": "agent_todos", - } - for item in monitor_items - ], - } - return payload +# Cold-sidecar inputs only: these named synthetic v0 summaries are not current +# quota output or historical archive validation. Keep them independent of the +# quota producer so the legacy comparison cannot reintroduce packet writes. +LEGACY_SYNTHETIC_SUMMARIES = { + "advancement": ( + "actor=agent user_action_required=false agent_action_required=true " + "quiet_noop_allowed=false lane=advancement_task llm=no_api " + "agent_action=advance the protocol simplification comparison with validation" + ), + "user_action": ( + "actor=agent_with_user_gate user_action_required=true agent_action_required=true " + "quiet_noop_allowed=false lane=advancement_task llm=no_api " + "user_action_pending=true user_action=decide whether to approve a setup check " + "agent_action=advance independent scope-bounded work with validation" + ), + "monitor_quiet": ( + "actor=agent user_action_required=false agent_action_required=false " + "quiet_noop_allowed=true lane=continuous_monitor llm=no_api " + "agent_action=quiet no-op until the monitor reports a material transition" + ), +} def deterministic_router_summary(rule_summary: str) -> str: @@ -194,8 +65,7 @@ def boundary_forbidden_terms() -> tuple[str, ...]: ) -def comparison_for(scenario_id: str, guard: dict) -> dict: - rule_summary = guard["protocol_action_packet"]["summary"] +def comparison_for(scenario_id: str, rule_summary: str) -> dict: router_summary = deterministic_router_summary(rule_summary) facts = required_facts(rule_summary) required_terms = { @@ -229,66 +99,15 @@ def comparison_for(scenario_id: str, guard: dict) -> dict: def build_comparison_report() -> dict: - scenarios = [ - ( - "advancement", - status_payload( - scenario_id="advancement", - agent_todos=[ - todo( - 1, - "[P1] Protocol simplification comparison: measure rule packet against a cold-path router summary.", - task_class="advancement_task", - ) - ], - ), - ), - ( - "user_action", - status_payload( - scenario_id="user_action", - agent_todos=[ - todo( - 1, - "[P2] Meta canary/readiness observation lane: keep status health observable.", - priority="P2", - task_class="continuous_monitor", - ) - ], - user_todos=[ - todo( - 1, - "[P1] Decide whether to approve a no-submit setup check.", - role="user", - task_class="advancement_task", - ) - ], - ), - ), - ( - "monitor_quiet", - status_payload( - scenario_id="monitor_quiet", - agent_todos=[ - todo( - 1, - "[P2] Meta canary/readiness observation lane: keep status health observable.", - priority="P2", - task_class="continuous_monitor", - ) - ], - next_action="Stay quiet until the monitor lane reports a material transition.", - ), - ), - ] comparisons = [ - comparison_for(scenario_id, build_quota_should_run(payload, goal_id=GOAL_ID)) - for scenario_id, payload in scenarios + comparison_for(scenario_id, summary) + for scenario_id, summary in LEGACY_SYNTHETIC_SUMMARIES.items() ] return { "schema_version": SCHEMA_VERSION, "mode": "deterministic_cold_path_fixture", "input_schema": "protocol_action_packet_v0", + "input_source": "legacy_synthetic_fixture", "codex_cli_invoked": False, "direct_llm_api_invoked": False, "env_read": False, @@ -304,7 +123,7 @@ def build_comparison_report() -> dict: "decision": { "direct_llm_api": "defer", "codex_cli": "optional_cold_path_only", - "hot_path": "keep_protocol_action_packet_v0_rule_baseline", + "hot_path": "candidate_structured_quota_contracts_without_legacy_packet", "next_step": "if needed, compare this fixture against an actual Codex CLI summary outside quota should-run", }, } @@ -313,6 +132,7 @@ def build_comparison_report() -> dict: def main() -> None: report = build_comparison_report() assert report["schema_version"] == SCHEMA_VERSION, report + assert report["input_source"] == "legacy_synthetic_fixture", report assert report["codex_cli_invoked"] is False, report assert report["direct_llm_api_invoked"] is False, report assert report["env_read"] is False, report diff --git a/examples/protocol/protocol-action-packet-smoke.py b/examples/protocol/quota-without-legacy-packet-smoke.py similarity index 78% rename from examples/protocol/protocol-action-packet-smoke.py rename to examples/protocol/quota-without-legacy-packet-smoke.py index 0ec1145f9f..9985d75e6f 100644 --- a/examples/protocol/protocol-action-packet-smoke.py +++ b/examples/protocol/quota-without-legacy-packet-smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Smoke-test compact protocol action packets for quota should-run.""" +"""Smoke-test structured quota decisions without the legacy action packet.""" from __future__ import annotations @@ -9,11 +9,11 @@ REPO_ROOT = Path(__file__).resolve().parents[2] 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 SchedulerRuntimeProfile, scheduler_execution_context_for_runtime_profile, ) -from loopx.quota import ( +from loopx.quota import ( # noqa: E402 build_quota_should_run as _build_quota_should_run, render_quota_should_run_markdown, ) @@ -41,14 +41,16 @@ def build_quota_should_run(*args, **kwargs): kwargs.setdefault("scheduler_execution_context", CODEX_APP_SCHEDULER_CONTEXT) - return _build_quota_should_run(*args, **kwargs) + guard = _build_quota_should_run(*args, **kwargs) + assert "protocol_action_packet" not in guard, guard + return guard def status_payload( *, agent_todos: list[dict], user_todos: list[dict] | None = None, - status: str = "protocol_packet_fixture", + status: str = "protocol_decision_fixture", next_action: str = "Advance the P1 protocol simplification spike.", ) -> dict: agent_summary = { @@ -211,7 +213,7 @@ def user_todo( return item -def assert_advancement_packet_prefers_backlog_candidate() -> None: +def assert_advancement_decision_prefers_backlog_candidate() -> None: guard = build_quota_should_run( status_payload( agent_todos=[ @@ -221,23 +223,24 @@ def assert_advancement_packet_prefers_backlog_candidate() -> None: ), goal_id=GOAL_ID, ) - packet = guard["protocol_action_packet"] - assert packet["schema_version"] == "protocol_action_packet_v0", packet - assert "actor=agent" in packet["summary"], packet - assert "user_action_required=false" in packet["summary"], packet - assert "agent_action_required=true" in packet["summary"], packet - assert "quiet_noop_allowed=false" in packet["summary"], packet - assert "LLM-assisted protocol simplification" in packet["summary"], packet - assert "compare a deterministic" not in packet["summary"], packet - assert "llm=no_api" in packet["summary"], packet + contract = guard["interaction_contract"] + assert contract["mode"] == "bounded_delivery", contract + assert contract["user_channel"]["action_required"] is False, contract + assert contract["agent_channel"]["must_attempt"] is True, contract + assert contract["agent_channel"]["quiet_noop_allowed"] is False, contract + assert contract["agent_channel"]["primary_action"] == ( + "[P1] LLM-assisted protocol simplification research spike" + ), contract + assert guard["work_lane_contract"]["lane"] == "advancement_task", guard + assert guard["scheduler_hint"]["action"] == "run_now", guard summary = guard["agent_todo_summary"] assert summary["first_executable_items"][0]["text"] == ADVANCEMENT_TODO, summary assert summary["monitor_open_items"][0]["text"] == MONITOR_TODO, summary markdown = render_quota_should_run_markdown(guard) - assert "protocol_action_packet: schema=protocol_action_packet_v0 actor=agent" in markdown, markdown + assert "protocol_action_packet" not in markdown, markdown -def assert_advancement_packet_keeps_user_todo_pending() -> None: +def assert_advancement_decision_keeps_user_todo_pending() -> None: guard = build_quota_should_run( status_payload( agent_todos=[ @@ -247,19 +250,15 @@ def assert_advancement_packet_keeps_user_todo_pending() -> None: ), goal_id=GOAL_ID, ) - packet = guard["protocol_action_packet"] contract = guard["interaction_contract"] - assert "actor=agent_with_user_gate" in packet["summary"], packet - assert "user_action_required=true" in packet["summary"], packet - assert "agent_action_required=true" in packet["summary"], packet - assert "user_action_pending=true" in packet["summary"], packet - assert f"user_action={USER_TODO}" in packet["summary"], packet - assert "agent_action=[P1] LLM-assisted protocol simplification research spike" in packet["summary"], packet - assert f"agent_action={USER_TODO}" not in packet["summary"], packet assert contract["mode"] == "bounded_delivery_with_user_notice", contract assert contract["user_channel"]["action_required"] is True, contract assert contract["user_channel"]["notify"] == "NOTIFY", contract assert contract["agent_channel"]["must_attempt"] is True, contract + assert contract["user_channel"]["actions"] == [USER_TODO], contract + assert contract["agent_channel"]["primary_action"] == ( + "[P1] LLM-assisted protocol simplification research spike" + ), contract def assert_explicit_user_gate_still_allows_independent_agent_action() -> None: @@ -283,18 +282,16 @@ def assert_explicit_user_gate_still_allows_independent_agent_action() -> None: ), goal_id=GOAL_ID, ) - packet = guard["protocol_action_packet"] contract = guard["interaction_contract"] user_summary = guard["user_todo_summary"] assert guard["requires_user_action"] is True, guard assert guard["notify_user_on_gate"] is True, guard assert guard["open_todo_notification_policy"] == "repeat_until_resolved", guard assert user_summary["gate_open_items"][0]["text"] == gate_todo, user_summary - assert "actor=agent_with_user_gate" in packet["summary"], packet - assert "user_action_required=true" in packet["summary"], packet - assert "agent_action_required=true" in packet["summary"], packet - assert "user_action=[P0] Approve one no-upload Terminal-Bench rerun" in packet["summary"], packet - assert "agent_action=[P1] LLM-assisted protocol simplification" in packet["summary"], packet + assert len(contract["user_channel"]["actions"]) == 1, contract + assert contract["user_channel"]["actions"][0].startswith( + "[P0] Approve one no-upload Terminal-Bench rerun" + ), contract assert contract["mode"] == "bounded_delivery_with_user_notice", contract assert contract["user_channel"]["action_required"] is True, contract assert contract["user_channel"]["notify"] == "NOTIFY", contract @@ -304,7 +301,7 @@ def assert_explicit_user_gate_still_allows_independent_agent_action() -> None: ), contract -def assert_monitor_only_packet_keeps_user_todo_pending() -> None: +def assert_monitor_only_decision_keeps_user_todo_pending() -> None: guard = build_quota_should_run( status_payload( status="monitor_fixture", @@ -315,17 +312,10 @@ def assert_monitor_only_packet_keeps_user_todo_pending() -> None: ), goal_id=GOAL_ID, ) - packet = guard["protocol_action_packet"] contract = guard["interaction_contract"] - assert "actor=agent_with_user_gate" in packet["summary"], packet - assert "user_action_required=true" in packet["summary"], packet - assert "agent_action_required=true" in packet["summary"], packet - assert "quiet_noop_allowed=false" in packet["summary"], packet - assert "user_action_pending=true" in packet["summary"], packet - assert "agent_action=repair the selected continuous_monitor todo" in packet["summary"], packet - assert f"user_action={USER_TODO}" in packet["summary"], packet - assert USER_TODO in packet["summary"], packet assert guard.get("notify_user_on_open_todo") is None, guard + assert contract["user_channel"]["actions"] == [USER_TODO], contract + assert guard["work_lane_contract"]["obligation"] == "repair_monitor_schedule_metadata", guard assert contract["mode"] == "bounded_delivery_with_user_notice", contract assert contract["user_channel"]["action_required"] is True, contract assert contract["user_channel"]["notify"] == "NOTIFY", contract @@ -371,6 +361,7 @@ def assert_agent_scoped_user_gate_stays_diagnostic_only() -> None: assert guard["effective_action"] == "normal_run", guard assert guard["should_run"] is True, guard lane = guard["work_lane_contract"] + assert lane["lane"] == "advancement_task", lane assert lane["obligation"] == "repair_monitor_schedule_metadata", lane assert lane["monitor_policy"] == "repair_schedule_metadata_before_quiet_wait", lane contract = guard["interaction_contract"] @@ -398,20 +389,16 @@ def assert_explicit_non_gating_user_todo_stays_quiet() -> None: ), goal_id=GOAL_ID, ) - packet = guard["protocol_action_packet"] contract = guard["interaction_contract"] - assert "actor=agent" in packet["summary"], packet - assert "user_action_required=false" in packet["summary"], packet - assert "agent_action_required=true" in packet["summary"], packet - assert "quiet_noop_allowed=false" in packet["summary"], packet - assert "user_action_pending=true" not in packet["summary"], packet - assert "agent_action=repair the selected continuous_monitor todo" in packet["summary"], packet assert contract["mode"] == "bounded_delivery", contract assert contract["user_channel"]["action_required"] is False, contract + assert "actions" not in contract["user_channel"], contract assert contract["agent_channel"]["must_attempt"] is True, contract + assert contract["agent_channel"]["quiet_noop_allowed"] is False, contract + assert guard["work_lane_contract"]["obligation"] == "repair_monitor_schedule_metadata", guard -def assert_monitor_only_packet_requires_schedule_metadata_repair() -> None: +def assert_monitor_only_decision_requires_schedule_metadata_repair() -> None: guard = build_quota_should_run( status_payload( status="monitor_fixture", @@ -421,14 +408,8 @@ def assert_monitor_only_packet_requires_schedule_metadata_repair() -> None: ), goal_id=GOAL_ID, ) - packet = guard["protocol_action_packet"] - assert "actor=agent" in packet["summary"], packet - assert "user_action_required=false" in packet["summary"], packet - assert "agent_action_required=true" in packet["summary"], packet - assert "quiet_noop_allowed=false" in packet["summary"], packet - assert "lane=advancement_task" in packet["summary"], packet - assert "agent_action=repair the selected continuous_monitor todo" in packet["summary"], packet lane = guard["work_lane_contract"] + assert lane["lane"] == "advancement_task", lane assert lane["obligation"] == "repair_monitor_schedule_metadata", lane assert lane["monitor_policy"] == "repair_schedule_metadata_before_quiet_wait", lane @@ -460,9 +441,6 @@ def assert_executable_recommended_action_overrides_monitor_todo() -> None: assert lane["obligation"] == "materialize_advancement_todo_or_blocker", lane assert lane["reason_codes"] == ["monitor_todo_only", "next_action_requires_advancement"], lane assert guard["execution_obligation"]["must_attempt_work"] is True, guard - packet = guard["protocol_action_packet"] - assert "agent_action_required=true" in packet["summary"], packet - assert "Collect or aggregate additional same-protocol" in packet["summary"], packet contract = guard["interaction_contract"] assert contract["agent_channel"]["must_attempt"] is True, contract assert ( @@ -493,11 +471,11 @@ def assert_goal_scoped_primary_action_ignores_foreign_backlog() -> None: ) payload["attention_queue"]["autonomous_backlog_candidates"]["open_count"] = 2 guard = build_quota_should_run(payload, goal_id=GOAL_ID) - packet = guard["protocol_action_packet"] contract = guard["interaction_contract"] - assert "Foreign Terminal-Bench" not in packet["summary"], packet assert "Foreign Terminal-Bench" not in contract["agent_channel"]["primary_action"], contract - assert "LLM-assisted protocol simplification" in packet["summary"], packet + assert contract["agent_channel"]["primary_action"] == ( + "[P1] LLM-assisted protocol simplification research spike" + ), contract assert len(guard["autonomous_backlog_candidates"]["items"]) == 1, guard assert ( guard["autonomous_backlog_candidates"]["items"][0]["goal_id"] == GOAL_ID @@ -505,16 +483,16 @@ def assert_goal_scoped_primary_action_ignores_foreign_backlog() -> None: def main() -> None: - assert_advancement_packet_prefers_backlog_candidate() - assert_advancement_packet_keeps_user_todo_pending() + assert_advancement_decision_prefers_backlog_candidate() + assert_advancement_decision_keeps_user_todo_pending() assert_explicit_user_gate_still_allows_independent_agent_action() - assert_monitor_only_packet_keeps_user_todo_pending() + assert_monitor_only_decision_keeps_user_todo_pending() assert_agent_scoped_user_gate_stays_diagnostic_only() assert_explicit_non_gating_user_todo_stays_quiet() - assert_monitor_only_packet_requires_schedule_metadata_repair() + assert_monitor_only_decision_requires_schedule_metadata_repair() assert_executable_recommended_action_overrides_monitor_todo() assert_goal_scoped_primary_action_ignores_foreign_backlog() - print("ok: protocol action packet smoke") + print("ok: quota without legacy packet smoke") if __name__ == "__main__": diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index a5bba1ba63..ca5309747b 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -252,7 +252,7 @@ "work_lane_contract": (29, 3), "external_evidence_observation": (8, 1), "goal_boundary": (30, 2), - "protocol_action_packet": (5, 2), + "protocol_action_packet": (1, 2), } # B3 migration surface: modules that read, write or bind the legacy field, plus # the modules holding its name unresolved, which have to be investigated before @@ -265,7 +265,7 @@ "work_lane_contract": (29, 3), "external_evidence_observation": (7, 1), "goal_boundary": (16, 1), - "protocol_action_packet": (5, 2), + "protocol_action_packet": (1, 2), } RETIREMENT_FIELD_KEYS = { "python_module_budget", "typescript_module_budget", diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index ea27270b1d..25031f96f5 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -17,10 +17,7 @@ from .settlement import ( read_heartbeat_settlement, ) -from ..work_items.interaction_contract import ( - build_interaction_contract, - build_protocol_action_packet, -) +from ..work_items.interaction_contract import build_interaction_contract from ..work_items.action_portfolio import reconcile_retained_action_selection from ..work_items.autonomous_replan_obligation import ( replan_obligation_id_from_packet, @@ -121,7 +118,6 @@ def _apply_retained_action_selection_reentry( turn_instance_id=turn_instance_id, runtime_root=str(runtime_root), ) - payload["protocol_action_packet"] = build_protocol_action_packet(payload) def _fresh_read_covers_all_pending_material( @@ -185,7 +181,7 @@ def _project_turn_start_required_reads( turn_instance_id: str | None, runtime_root: Path, ) -> bool: - """Order evidence before work; return whether the packet needs rendering.""" + """Order evidence before work and report whether the decision changed.""" projected = _turn_start_required_reads(dispatch) if not projected: @@ -271,7 +267,7 @@ def _apply_pending_capability_intent_precedence( ) = None, turn_instance_id: str | None = None, ) -> bool: - """Apply intent precedence; return whether the packet needs rendering.""" + """Apply intent precedence and report whether the decision changed.""" if not isinstance(projection, Mapping) or projection.get("state") != "pending": return False @@ -593,7 +589,7 @@ def build_live_quota_should_run_decision( turn_start_hook_dispatch, registry=registry_path, runtime_root=runtime_root, goal_id=goal_id, agent_id=agent_id, ) - packet_changed = _project_turn_start_required_reads( + _project_turn_start_required_reads( payload, turn_start_hook_dispatch, available_capabilities=available_capabilities, @@ -604,21 +600,16 @@ def build_live_quota_should_run_decision( hook_dispatch = dispatch_interaction_projection_hooks(interaction_projection_hooks) projections = hook_dispatch["projections"] if isinstance(projections, Mapping): - intent_changed = _apply_pending_capability_intent_precedence( + _apply_pending_capability_intent_precedence( payload, projections.get("pending_capability_intent"), available_capabilities=available_capabilities, scheduler_execution_context=resolved_context, turn_instance_id=turn_instance_id, ) - packet_changed = packet_changed or intent_changed interaction = payload.get("interaction_contract") if isinstance(interaction, dict): interaction.update(projections) - # Neither projection consumes the intermediate packet. Recovery below does - # require a complete decision, so finalize this projection stage here. - if packet_changed: - payload["protocol_action_packet"] = build_protocol_action_packet(payload) # A settled receipt owns this host Turn until it ends. Looking for an older # unsettled Turn here can overwrite the settled-skip route with a recovery # obligation and then select a successor against the immutable receipt diff --git a/loopx/control_plane/quota/should_run.py b/loopx/control_plane/quota/should_run.py index e6424871a5..80d9466fed 100644 --- a/loopx/control_plane/quota/should_run.py +++ b/loopx/control_plane/quota/should_run.py @@ -38,7 +38,6 @@ from ..todos.write_hint import build_todo_write_hint from ..work_items.interaction_contract import ( build_interaction_contract, - build_protocol_action_packet, ) from .effect_program import ( ReceiptBoundMonitorPhase, @@ -245,7 +244,6 @@ def build_quota_paused_should_run_payload( codex_app_automation_id=codex_app_automation_id, scheduler_execution_context=resolved_scheduler_context, ) - payload["protocol_action_packet"] = build_protocol_action_packet(payload) return payload diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 1d536cc2e0..7d2aef285e 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -110,7 +110,6 @@ from ..work_items.interaction_contract import ( build_interaction_contract, unadmitted_action_selection, - build_protocol_action_packet, finalize_user_gate_notification_cooldown, ) from ..work_items.interaction_contract import ( @@ -1434,7 +1433,6 @@ def _build_quota_should_run_payload( interaction_contract=payload.get("interaction_contract"), scheduler_hint=payload.get("scheduler_hint"), ) - payload["protocol_action_packet"] = build_protocol_action_packet(payload) return payload diff --git a/loopx/control_plane/quota/unsettled_host_turn.py b/loopx/control_plane/quota/unsettled_host_turn.py index fa7e3e18e2..2966538b06 100644 --- a/loopx/control_plane/quota/unsettled_host_turn.py +++ b/loopx/control_plane/quota/unsettled_host_turn.py @@ -21,7 +21,6 @@ from ..todos.todo_semantics import todo_item_task_class from ..work_items.interaction_contract import ( build_interaction_contract, - build_protocol_action_packet, ) from .error_codes import HeartbeatReceiptIdentityConflictError from .monitor_poll import find_quota_monitor_poll_turn @@ -343,5 +342,4 @@ def apply_unsettled_host_turn_recovery_if_required( if isinstance(cli_channel, dict): cli_channel["recovery_ref"] = "$.unsettled_host_turn_recovery" payload["interaction_contract"] = interaction_contract - payload["protocol_action_packet"] = build_protocol_action_packet(payload) return True diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 8f91d84717..f4fd645ef2 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -57,7 +57,6 @@ INTERACTION_CONTRACT_SCHEMA_VERSION = "loopx_interaction_contract_v0" INTERACTION_RESPONSE_PLAN_SCHEMA_VERSION = "interaction_response_plan_v0" -PROTOCOL_ACTION_PACKET_SCHEMA_VERSION = "protocol_action_packet_v0" PROTOCOL_ACTION_PACKET_LLM_POLICY = "no_api" AUXILIARY_MONITOR_POLL_CLI_SCHEMA_VERSION = "auxiliary_monitor_poll_cli_v0" AUXILIARY_MONITOR_OBSERVATION_INPUT_SCHEMA_VERSION = ( @@ -440,14 +439,6 @@ def render_protocol_action_packet_summary(fields: dict[str, Any]) -> str: return " ".join(parts) -def build_protocol_action_packet(payload: dict[str, Any]) -> dict[str, Any]: - fields = protocol_action_packet_fields(payload) - return { - "schema_version": PROTOCOL_ACTION_PACKET_SCHEMA_VERSION, - "summary": render_protocol_action_packet_summary(fields), - } - - def _interaction_mode(payload: dict[str, Any]) -> str: execution_obligation = ( payload.get("execution_obligation") diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 038ee26498..a390dc626c 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -1108,9 +1108,9 @@ "typescript_migration_surface": 1 }, "protocol_action_packet": { - "python_module_budget": 5, + "python_module_budget": 1, "typescript_module_budget": 2, - "python_migration_surface": 5, + "python_migration_surface": 1, "typescript_migration_surface": 2 } } diff --git a/scripts/verify_protocol_packet_migration.py b/scripts/verify_protocol_packet_migration.py new file mode 100644 index 0000000000..8f74a0fcf9 --- /dev/null +++ b/scripts/verify_protocol_packet_migration.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Check new quota outputs and frozen v0 history with an unmodified v1.1.0 reader. + +Prepare that checkout and its Node dependencies explicitly. This command never +fetches sources, installs dependencies, writes receipts, or changes the checkout. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from loopx.control_plane.effect_program import ReceiptBoundReplayPhase # noqa: E402 +from loopx.control_plane.quota.should_run import build_quota_should_run # noqa: E402 +from loopx.control_plane.quota.turn_envelope import build_turn_envelope # noqa: E402 +from loopx.control_plane.testing.quota_fixtures import quota_status_payload # noqa: E402 + +READER_REVISION = "607c11d75e9d608e44d9caf6b675b32c0abb8267" +READER = ''' +import json, sys +from pathlib import Path +root = Path(sys.argv[1]).resolve() +sys.path.insert(0, str(root)) +import loopx +assert Path(loopx.__file__).resolve().is_relative_to(root), "wrong reader source" +from loopx.control_plane.effect_program import interpret_quota_should_run_packet +from loopx.control_plane.quota.turn_envelope import quota_action_signature_document, turn_envelope_action_signature_document +from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority +cases = json.load(sys.stdin) +for case in cases: + decision, envelope = case["decision"], case["envelope"] + before = json.dumps(case, sort_keys=True) + turn = interpret_quota_should_run_packet(decision) + assert turn.observation.should_run == decision["should_run"], case["id"] + assert turn.observation.protocol_summary == decision.get("protocol_action_packet", {}).get("summary"), case["id"] + assert quota_action_signature_document(decision) == turn_envelope_action_signature_document(envelope), case["id"] + assert extract_turn_authority({"turn_envelope": envelope})["primary_action"], case["id"] + assert json.dumps(case, sort_keys=True) == before, case["id"] +print(json.dumps({"reader_cases": len(cases), "ok": True})) +''' + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--reader-checkout", type=Path, required=True) + args = parser.parse_args() + reader = args.reader_checkout.resolve() + revision = subprocess.check_output(["git", "-C", str(reader), "rev-parse", "HEAD"], text=True).strip() + if revision != READER_REVISION: + parser.error(f"reader checkout must be v1.1.0 commit {READER_REVISION}; got {revision}") + clean = subprocess.run(["git", "-C", str(reader), "diff", "--quiet", "HEAD", "--", "loopx"]) + if clean.returncode: + parser.error("reader checkout has modified runtime sources; use unmodified v1.1.0") + history = json.loads((ROOT / "tests/fixtures/protocol_packet_history_v0.json").read_text(encoding="utf-8")) + cases = [{"id": c["id"], "decision": c["decision"], "envelope": c["envelope"]} for c in history["cases"]] + for state in ("eligible", "paused", "operator_gate", "exhausted"): + for settled in (False, True): + status = quota_status_payload( + goal_id="migration-reader-fixture", status="active", quota_state=state, + recommended_action="Verify the bounded migration", + ) + decision = build_quota_should_run( + status, goal_id="migration-reader-fixture", available_capabilities=["shell"], + receipt_bound_replay_phase=ReceiptBoundReplayPhase.SETTLED if settled else None, + ) + assert "protocol_action_packet" not in decision + cases.append({"id": f"new/{state}/{settled}", "decision": decision, "envelope": build_turn_envelope(decision)}) + result = subprocess.run( + [sys.executable, "-I", "-c", READER, str(reader)], cwd=reader, + input=json.dumps(cases), capture_output=True, text=True, timeout=120, + ) + if result.returncode: + raise RuntimeError(f"v1.1.0 readback failed:\n{result.stdout}\n{result.stderr}") + report = json.loads(result.stdout) + assert report == {"reader_cases": len(cases), "ok": True} + print(json.dumps({**report, "new_outputs": 8, "frozen_history": len(history["cases"]), "reader_sha": revision})) + + +if __name__ == "__main__": + main() diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 6509d62086..ae7e5e6c7d 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -777,7 +777,7 @@ def _retirement_registry(python_surface: int, typescript_surface: int) -> dict: """A one-field ledger shaped like the real one, for the B3 budget checks.""" return {"retirement_ledger": {"should_run_legacy_decision_fields": {"fields": { "protocol_action_packet": { - "python_module_budget": 5, + "python_module_budget": 1, "typescript_module_budget": 2, "python_migration_surface": python_surface, "typescript_migration_surface": typescript_surface, @@ -789,10 +789,10 @@ def test_a_new_reader_of_a_legacy_field_exceeds_its_migration_surface() -> None: smoke = runpy.run_path(str(SMOKE)) readers = [ smoke["SourceFile"](f"loopx/probe_{index}.py", ".py", 'value = payload["protocol_action_packet"]') - for index in range(6) + for index in range(2) ] with pytest.raises(smoke["Drift"], match="modules migrated"): - smoke["check_reader_metric"](_retirement_registry(5, 2), readers) + smoke["check_reader_metric"](_retirement_registry(1, 2), readers) def test_migration_surface_budget_cannot_move_without_its_anchor() -> None: @@ -807,7 +807,7 @@ def test_the_reader_metric_reports_all_five_facts_beside_the_role_labels() -> No "loopx/projection.py", ".py", 'payload["protocol_action_packet"] = payload.get("protocol_action_packet")', )] - _, detail = smoke["check_reader_metric"](_retirement_registry(5, 2), sources) + _, detail = smoke["check_reader_metric"](_retirement_registry(1, 2), sources) line = next(item for item in detail if item.startswith("protocol_action_packet.py")) assert "reader=1 writer=0" in line, line assert "reads=1 writes=1" in line, ( @@ -824,9 +824,9 @@ def test_an_unresolved_name_carrier_is_inside_the_migration_surface() -> None: "loopx/carrier.py", ".py", 'LEGACY = ["protocol_action_packet"]\nfor name in LEGACY:\n emit(name)\n', )] - _, detail = smoke["check_reader_metric"](_retirement_registry(5, 2), sources) + _, detail = smoke["check_reader_metric"](_retirement_registry(1, 2), sources) line = next(item for item in detail if item.startswith("protocol_action_packet.py")) - assert "surface=1/5" in line and "unresolved=1" in line, ( + assert "surface=1/1" in line and "unresolved=1" in line, ( "the field name is in this module as data; that is work to investigate before the " f"field can go, so it belongs to the surface: {line}" ) @@ -838,8 +838,8 @@ def test_prose_and_same_prefix_identifiers_do_not_consume_the_migration_surface( smoke["SourceFile"]("loopx/prose.py", ".py", '"""protocol_action_packet is published downstream."""'), smoke["SourceFile"]("loopx/prefix.py", ".py", 'value = payload["protocol_action_packet_v2"]'), ] - report, detail = smoke["check_reader_metric"](_retirement_registry(5, 2), sources) - assert any("surface=0/5" in line and "mention=1" in line for line in detail), detail + report, detail = smoke["check_reader_metric"](_retirement_registry(1, 2), sources) + assert any("surface=0/1" in line and "mention=1" in line for line in detail), detail assert any("dynamic_mapping_key_sites=0" in line for line in report), report def _with_unexecuted_projection(registry: dict) -> dict: diff --git a/tests/control_plane/test_effect_interpreter_packet.py b/tests/control_plane/test_effect_interpreter_packet.py index c51d1b4f5f..1ed13d7e37 100644 --- a/tests/control_plane/test_effect_interpreter_packet.py +++ b/tests/control_plane/test_effect_interpreter_packet.py @@ -49,8 +49,9 @@ def _advancement_payload() -> dict: ) -def test_quota_should_run_exposes_canonical_effect_slots() -> None: +def test_quota_should_run_exposes_canonical_effect_slots_without_legacy_packet() -> None: packet = build_quota_should_run(_advancement_payload(), goal_id=GOAL_ID) + assert "protocol_action_packet" not in packet turn = interpret_quota_should_run_packet( packet, goal_id=GOAL_ID, @@ -72,7 +73,7 @@ def test_quota_should_run_exposes_canonical_effect_slots() -> None: assert turn.observation.should_run is True assert turn.observation.effective_action == "normal_run" assert turn.observation.recommended_action == "[P1] Advance the bounded slice." - assert "lane=advancement_task" in turn.observation.protocol_summary + assert turn.observation.protocol_summary is None # next effect assert turn.next_effect.cli_actions diff --git a/tests/control_plane/test_effect_turn_live_quota_decision.py b/tests/control_plane/test_effect_turn_live_quota_decision.py index d352ff8d65..3779020f6c 100644 --- a/tests/control_plane/test_effect_turn_live_quota_decision.py +++ b/tests/control_plane/test_effect_turn_live_quota_decision.py @@ -1006,105 +1006,138 @@ def test_prior_closeout_identity_conflict_fails_closed( ) -@pytest.mark.parametrize("status_name", ["active", "paused"]) -@pytest.mark.parametrize("reads", [False, True]) -@pytest.mark.parametrize("intent", ["absent", "pending", "invalid"]) -def test_live_projection_stage_renders_one_complete_packet( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - status_name: str, reads: bool, intent: str, +@pytest.mark.parametrize("quota_state", ["eligible", "paused", "operator_gate", "exhausted"]) +@pytest.mark.parametrize("required_reads", [False, True]) +def test_current_quota_outputs_retire_packet_without_losing_signed_actions( + tmp_path: Path, quota_state: str, required_reads: bool, ) -> None: - """Coalescing must preserve the full decision and signed envelope input.""" - from copy import deepcopy - from loopx.control_plane.quota import live_decision + from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, quota_action_signature_document, + turn_envelope_action_signature_document, + ) + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority + + status = quota_status_payload( + goal_id=GOAL_ID, status="active", quota_state=quota_state, + agent_todo_items=[{ + "todo_id": "todo_packet_retirement", "index": 1, + "text": "[P1] Verify the bounded change", "role": "agent", + "status": "open", "priority": "P1", "task_class": "advancement_task", + }], + recommended_action="Verify the bounded change", + ) + payload = build_live_quota_should_run_decision( + status, goal_id=GOAL_ID, agent_id=None, available_capabilities=["shell"], + include_scheduler_detail=False, codex_app_current_rrule=None, + registry_path=tmp_path / "registry.json", runtime_root=tmp_path / "runtime", + turn_start_hook_dispatch=_turn_start_dispatch(required=required_reads), + ) + assert "protocol_action_packet" not in payload + envelope = build_turn_envelope(payload) + assert "protocol_action_packet" not in envelope["contract_capsule"] + assert quota_action_signature_document(payload) == turn_envelope_action_signature_document(envelope) + assert extract_turn_authority({"turn_envelope": envelope})["primary_action"] + if quota_state == "paused": + assert payload["should_run"] is False + assert envelope["writeback"]["spend_allowed_now"] is False + if required_reads: + assert payload["interaction_contract"]["agent_channel"]["required_reads"] + + +def test_packet_retirement_preserves_reads_and_independent_capability_command(tmp_path: Path) -> None: from loopx.control_plane.capability_hooks import ( InteractionProjectionHookRegistration, INTERACTION_PROJECTION_HOOK_RESULT_SCHEMA_VERSION, ) - from loopx.control_plane.quota.turn_envelope import quota_action_signature_document - - projection = { - "schema_version": "pending_capability_intent_projection_v0", - "capability_id": "periodic-report", - "intent_kind": "periodic_report.trigger_evaluation", - "idempotency_key": "periodic-report:fixture", - "intent_digest": "sha256:" + "a" * 64, - "goal_id": GOAL_ID, - "agent_id": "fixture-agent", - "state": "pending", - "action_kind": "consume_periodic_report_intent", - "action_summary": "Generate the exact report and queue configured delivery.", - "command": f"loopx periodic-report consume-pending --goal-id {GOAL_ID} --agent-id fixture-agent --execute", - "generation_authorized": True, - "external_delivery_authorized": True, - "agent_read_required": True, - } - if intent == "invalid": - projection["command"] = "" + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority + + command = f"loopx periodic-report consume-pending --goal-id {GOAL_ID} --agent-id fixture-agent --execute" hook = InteractionProjectionHookRegistration( - hook_id="periodic_report.pending_intent", - capability_id="periodic-report", + hook_id="periodic_report.pending_intent", capability_id="periodic-report", projection_slots=("pending_capability_intent",), requested_read_scope=("post_writeback_intent_journal",), producer=lambda: { "schema_version": INTERACTION_PROJECTION_HOOK_RESULT_SCHEMA_VERSION, - "hook_id": "periodic_report.pending_intent", - "capability_id": "periodic-report", - "phase": "interaction_projection", - "status": "candidate", + "hook_id": "periodic_report.pending_intent", "capability_id": "periodic-report", + "phase": "interaction_projection", "status": "candidate", "projection_slot": "pending_capability_intent", - "payload": projection, + "payload": { + "schema_version": "pending_capability_intent_projection_v0", + "capability_id": "periodic-report", "intent_kind": "periodic_report.trigger_evaluation", + "idempotency_key": "periodic-report:fixture", "intent_digest": "sha256:" + "a" * 64, + "goal_id": GOAL_ID, "agent_id": "fixture-agent", "state": "pending", + "action_kind": "consume_periodic_report_intent", "action_summary": "Generate the exact report.", + "command": command, "generation_authorized": True, + "external_delivery_authorized": True, "agent_read_required": True, + }, }, ) - status = _ordinary_status_payload() - status["attention_queue"]["items"][0]["status"] = status_name - status["run_history"]["goals"][0]["status"] = status_name - kwargs = dict( - goal_id=GOAL_ID, agent_id=None, available_capabilities=["shell"], - include_scheduler_detail=False, codex_app_current_rrule=None, - registry_path=tmp_path / "registry.json", runtime_root=tmp_path / "runtime", - interaction_projection_hooks=[] if intent == "absent" else [hook], - turn_start_hook_dispatch=_turn_start_dispatch(required=reads), + payload = build_live_quota_should_run_decision( + _ordinary_status_payload(), goal_id=GOAL_ID, agent_id=None, + available_capabilities=["shell"], include_scheduler_detail=False, + codex_app_current_rrule=None, registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", interaction_projection_hooks=[hook], + turn_start_hook_dispatch=_turn_start_dispatch(), ) - rendered = [] - render = live_decision.build_protocol_action_packet - - def record_render(payload): - rendered.append(deepcopy(payload)) - return render(payload) - - recover = live_decision.apply_unsettled_host_turn_recovery_if_required + assert "protocol_action_packet" not in payload + assert payload["effective_action"] == "governed_capability_intent" + assert payload["normal_delivery_allowed"] is False + contract = payload["interaction_contract"] + assert contract["cli_channel"]["next_cli_actions"] == [command] + assert contract["agent_channel"]["required_reads"] + envelope = build_turn_envelope(payload) + assert "protocol_action_packet" not in envelope["contract_capsule"] + authority = extract_turn_authority({"turn_envelope": envelope}) + assert authority["primary_action"] == "Generate the exact report." + assert envelope["writeback"]["next_cli_actions"] == [command] + assert authority["required_reads"] + + +def test_retained_selection_reentry_stays_packet_free_and_signed( + tmp_path: Path, +) -> None: + from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, + quota_action_signature_document, + turn_envelope_action_signature_document, + ) + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority - def check_recovery_input(payload, **recovery_kwargs): - assert payload["protocol_action_packet"] == render(payload) - return recover(payload, **recovery_kwargs) + payload = build_live_quota_should_run_decision( + _ordinary_status_payload(), + goal_id=GOAL_ID, + agent_id=None, + available_capabilities=["shell"], + include_scheduler_detail=False, + codex_app_current_rrule=None, + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + retained_action_selection_todo_id="todo_retained_explicit", + turn_instance_id="turn-retained-packet-retirement", + ) - monkeypatch.setattr( - live_decision, "apply_unsettled_host_turn_recovery_if_required", check_recovery_input + assert payload["retained_action_selection"]["disposition"] == ( + "require_explicit_selection" ) - monkeypatch.setattr(live_decision, "build_protocol_action_packet", record_render) - actual = build_live_quota_should_run_decision(deepcopy(status), **kwargs) - assert len(rendered) == int(reads or intent == "pending") - if rendered: - assert actual["protocol_action_packet"] == render(actual) - if intent == "pending": - assert actual["effective_action"] == "governed_capability_intent" - assert actual["interaction_contract"]["cli_channel"]["next_cli_actions"] == [projection["command"]] - assert actual["heartbeat_recommendation"]["notify"] == "DONT_NOTIFY" - if reads: - assert actual["interaction_contract"]["agent_channel"]["required_reads"] - - # Reproduce the previous composition order: eagerly render after each - # changed helper, then compare every output field (including summary order). - for name in ("_project_turn_start_required_reads", "_apply_pending_capability_intent_precedence"): - original = getattr(live_decision, name) - - def eager(payload, *args, _original=original, **helper_kwargs): - changed = _original(payload, *args, **helper_kwargs) - if changed: - payload["protocol_action_packet"] = render(payload) - return False - - monkeypatch.setattr(live_decision, name, eager) - expected = build_live_quota_should_run_decision(deepcopy(status), **kwargs) - assert actual == expected - assert quota_action_signature_document(actual) == quota_action_signature_document(expected) + assert payload["state"] == "action_selection_required" + assert payload["should_run"] is False + assert "selected_todo" not in payload + assert "protocol_action_packet" not in payload + interaction = payload["interaction_contract"] + assert interaction["mode"] == "skip" + assert interaction["agent_channel"]["must_attempt"] is False + assert interaction["agent_channel"]["delivery_allowed"] is False + + envelope = build_turn_envelope(payload) + assert "protocol_action_packet" not in envelope["contract_capsule"] + assert quota_action_signature_document(payload) == ( + turn_envelope_action_signature_document(envelope) + ) + authority = extract_turn_authority({"turn_envelope": envelope}) + assert authority["primary_action"] == interaction["agent_channel"][ + "primary_action" + ] + assert authority["write_scope"] == [] + assert envelope["writeback"]["spend_allowed_now"] is False + assert envelope["writeback"]["spend_after_validation"] is False diff --git a/tests/control_plane/test_goal_vision_blocked_successor.py b/tests/control_plane/test_goal_vision_blocked_successor.py index ec2eeb4b43..d4cd204a9b 100644 --- a/tests/control_plane/test_goal_vision_blocked_successor.py +++ b/tests/control_plane/test_goal_vision_blocked_successor.py @@ -698,7 +698,7 @@ def test_exact_blocked_successor_defers_only_open_vision_gap( cli_actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] assert "quota monitor-poll" in cli_actions[0] assert "quota should-run" in cli_actions[1] - assert "agent_action_required=true" in guard["protocol_action_packet"]["summary"] + assert "protocol_action_packet" not in guard cli_wait = guard["interaction_contract"]["cli_channel"]["vision_wait_state"] assert cli_wait["selected_todo_id"] == WAITING_ID assert cli_wait["automatic_resume"] is True diff --git a/tests/control_plane/test_protocol_packet_history.py b/tests/control_plane/test_protocol_packet_history.py new file mode 100644 index 0000000000..ac28877d54 --- /dev/null +++ b/tests/control_plane/test_protocol_packet_history.py @@ -0,0 +1,167 @@ +"""Read frozen release objects through TS and host admission, never today's writer. + +These four synthetic inputs cover the v0 compatibility boundary, not a complete +external archive. Generation provenance and replay instructions live beside the +objects. Changing the pinned bytes requires reviewing the release provenance; +regenerating envelopes with the current writer would erase the regression oracle. +""" + +from copy import deepcopy +from hashlib import sha256 +import json +from pathlib import Path +from typing import Any + +import pytest + +from loopx.control_plane.effect_program import interpret_quota_should_run_packet +from loopx.control_plane.quota.turn_envelope import ( + quota_action_signature_document, + turn_envelope_action_signature_document, +) +from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority + + +FIXTURE = Path(__file__).parents[1] / "fixtures" / "protocol_packet_history_v0.json" +HISTORY = json.loads(FIXTURE.read_text(encoding="utf-8")) +CASES = HISTORY["cases"] +CASE_IDS = [case["id"] for case in CASES] +VERIFIED_SUMMARY = ( + "actor=agent user_action_required=false agent_action_required=true " + "quiet_noop_allowed=false llm=no_api agent_action=inspect the synthetic fixture" +) + + +def _canonical_hash(value: Any) -> str: + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return "sha256:" + sha256(encoded).hexdigest() + + +def test_history_is_pinned_to_the_release_generated_bytes() -> None: + assert HISTORY["source"]["git_sha"] == "607c11d75e9d608e44d9caf6b675b32c0abb8267" + assert HISTORY["source"]["release"] == "v1.1.0" + assert HISTORY["source"]["synthetic"] is True + assert CASE_IDS == ["verified", "residue", "opaque", "missing_packet"] + assert sha256(FIXTURE.read_bytes()).hexdigest() == ( + "da0520a7f1b41402229e31a690b6b5a5e1e83144cb21f9b2960d4aebd4dccb14" + ) + + +@pytest.mark.parametrize("case", CASES, ids=CASE_IDS) +def test_release_decision_and_stored_envelope_keep_exact_signature( + case: dict[str, Any], +) -> None: + decision, envelope = deepcopy(case["decision"]), deepcopy(case["envelope"]) + before = deepcopy((decision, envelope)) + absent = case["id"] == "missing_packet" + expected_action = { + "verified": "inspect the synthetic fixture", + "residue": "inspect the updated synthetic fixture", + "opaque": "inspect the synthetic fixture", + "missing_packet": "quiet no-op; no material transition", + }[case["id"]] + expected_summary = ( + None if absent else "synthetic opaque historical observation" + if case["id"] == "opaque" else VERIFIED_SUMMARY + ) + expected_commands = [] if absent else [ + "loopx status --goal-id history-fixture-goal" + ] + + # These public Python adapters call the real TypeScript bridge. No mocks, + # build_turn_envelope calls, or signature replacement occur in these tests. + turn = interpret_quota_should_run_packet(decision) + source_document = quota_action_signature_document(decision) + envelope_document = turn_envelope_action_signature_document(envelope) + signature = envelope["action_signature"] + assert source_document == envelope_document + assert _canonical_hash(decision) == signature["source_decision_hash"] + assert _canonical_hash(source_document) == signature["source_hash"] + assert _canonical_hash(envelope_document) == signature["envelope_hash"] + assert signature["source_hash"] == signature["envelope_hash"] + assert signature["matches"] is True + assert signature["coverage"] == "turn_envelope_action_dimensions_v0" + + authority = extract_turn_authority({"turn_envelope": envelope}) + assert authority == { + "primary_action": expected_action, + "required_reads": [], + "write_scope": ["tests/**"], + "workspace_guard": {}, + } + assert turn.observation.protocol_summary == expected_summary + assert turn.observation.should_run is (not absent) + assert turn.observation.effective_action == ("quota_skip" if absent else "normal_run") + assert list(turn.next_effect.cli_actions) == expected_commands + assert envelope["action"]["must_attempt"] is (not absent) + assert envelope["action"]["delivery_allowed"] is (not absent) + assert envelope["action"]["quiet_noop_allowed"] is absent + assert envelope["writeback"]["next_cli_actions"] == expected_commands + assert envelope["writeback"]["spend_allowed_now"] is False + assert envelope["writeback"]["spend_after_validation"] is (not absent) + assert envelope["execution_policy"]["safe_bypass_allowed"] is False + + capsule = envelope["contract_capsule"] + if absent: + assert "protocol_action_packet" not in decision + assert "protocol_action_packet" not in capsule + else: + witness = capsule["protocol_action_packet"] + assert witness["derivation_status"] == { + "verified": "verified", + "residue": "verified_with_residue", + "opaque": "unverified_retain_summary", + }[case["id"]] + assert witness["summary_hash"] == _canonical_hash(expected_summary) + assert witness["reconstruction_verified"] is (case["id"] != "opaque") + if case["id"] == "opaque": + assert witness["summary"] == expected_summary + else: + assert "summary" not in witness + if case["id"] == "residue": + assert witness["residue"] == {"agent_action": "inspect the synthetic fixture"} + else: + assert "residue" not in witness + assert (decision, envelope) == before + + +@pytest.mark.parametrize("case", CASES, ids=CASE_IDS) +@pytest.mark.parametrize( + "corruption", + ["missing_signature", "mismatch", "forged_equal_hashes", "action", "spend", "witness"], +) +def test_host_rejects_corrupted_release_envelope_without_resigning( + case: dict[str, Any], corruption: str, +) -> None: + envelope = deepcopy(case["envelope"]) + if corruption == "missing_signature": + envelope.pop("action_signature") + elif corruption == "mismatch": + envelope["action_signature"]["matches"] = False + elif corruption == "forged_equal_hashes": + envelope["action_signature"].update( + source_hash="sha256:" + "0" * 64, + envelope_hash="sha256:" + "0" * 64, + ) + elif corruption == "action": + envelope["action"]["primary_action"] = "unsigned replacement action" + elif corruption == "spend": + envelope["writeback"]["spend_allowed_now"] = True + else: + capsule = envelope["contract_capsule"] + if case["id"] == "missing_packet": + capsule["protocol_action_packet"] = { + "schema_version": "protocol_action_packet_v0", "summary": VERIFIED_SUMMARY, + } + elif case["id"] == "residue": + capsule["protocol_action_packet"].pop("residue") + elif case["id"] == "opaque": + capsule["protocol_action_packet"].pop("summary") + else: + capsule.pop("protocol_action_packet") + before = deepcopy(envelope) + with pytest.raises(ValueError, match="action signature is missing or does not match"): + extract_turn_authority({"turn_envelope": envelope}) + assert envelope == before diff --git a/tests/control_plane/test_protocol_packet_retirement_cli.py b/tests/control_plane/test_protocol_packet_retirement_cli.py new file mode 100644 index 0000000000..9caaf90bdb --- /dev/null +++ b/tests/control_plane/test_protocol_packet_retirement_cli.py @@ -0,0 +1,36 @@ +"""The real CLI output contract can omit the legacy observation safely.""" +from __future__ import annotations + +from pathlib import Path + +from loopx.control_plane.quota.turn_envelope import build_turn_envelope +from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority +from loopx.quota import render_quota_should_run_markdown +from test_quota_settlement_cli import AGENT_ID, GOAL_ID, _run_cli, _write_fixture + + +def test_real_cli_and_host_use_structured_contracts_without_packet(tmp_path: Path) -> None: + project, runtime, registry = _write_fixture(tmp_path) + guard = ( + "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-instance-id", "packet-retirement-cli", + "--scan-path", str(project), + ) + code, payload = _run_cli(registry, runtime, *guard) + assert code == 0, payload + assert "protocol_action_packet" not in payload + assert "protocol_action_packet:" not in render_quota_should_run_markdown(payload) + assert payload["interaction_contract"]["agent_channel"]["primary_action"] + envelope = build_turn_envelope(payload) + authority = extract_turn_authority({"turn_envelope": envelope}) + assert "protocol_action_packet" not in envelope["contract_capsule"] + assert authority["primary_action"] + identity = payload["heartbeat_receipt"].get("settlement_identity") + + code, replay = _run_cli(registry, runtime, *guard) + assert code == 0, replay + assert "protocol_action_packet" not in replay + assert replay["heartbeat_receipt"].get("settlement_identity") == identity + assert replay["heartbeat_receipt"]["status"] == "replayed" + assert replay["rollout_event"]["appended"] is False + assert "protocol_action_packet" not in build_turn_envelope(replay)["contract_capsule"] diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index e6ae0cb5bd..a5f7bfc244 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -50,10 +50,7 @@ def _assert_action_selection_recovery_projections(payload: dict[str, Any]) -> No assert payload["execution_obligation"]["must_attempt_work"] is False assert payload["automation_liveness"]["automation_action"] == "keep_active" assert payload["scheduler_hint"]["action"] == "backoff_until_state_change" - protocol_summary = payload["protocol_action_packet"]["summary"] - assert "agent_action_required=false" in protocol_summary - assert "agent_action_required=true" not in protocol_summary - assert "execute_bounded_work" not in protocol_summary + assert "protocol_action_packet" not in payload for field in ( "autonomous_replan_obligation", "replan_action_packet", @@ -63,6 +60,7 @@ def _assert_action_selection_recovery_projections(payload: dict[str, Any]) -> No assert field not in payload envelope = build_turn_envelope(payload) + assert "protocol_action_packet" not in envelope["contract_capsule"] assert envelope["contract_capsule"]["interaction_contract"]["mode"] == "skip" assert envelope["contract_capsule"]["execution_obligation"][ "must_attempt_work" diff --git a/tests/control_plane/test_unadmitted_selection_construction.py b/tests/control_plane/test_unadmitted_selection_construction.py index b3fc90d42c..f38d742394 100644 --- a/tests/control_plane/test_unadmitted_selection_construction.py +++ b/tests/control_plane/test_unadmitted_selection_construction.py @@ -174,14 +174,13 @@ def test_scoped_fallback_cannot_execute_under_an_unadmitted_selection(): assert interaction["cli_channel"]["spend_allowed_now"] is False assert interaction["cli_channel"]["spend_after_validation"] is False - summary = payload["protocol_action_packet"]["summary"] - assert "agent_action_required=false" in summary - assert "agent_action_required=true" not in summary + assert "protocol_action_packet" not in payload envelope = build_turn_envelope(payload) assert envelope["writeback"]["spend_allowed_now"] is False assert envelope["writeback"]["spend_after_validation"] is False capsule = envelope["contract_capsule"] + assert "protocol_action_packet" not in capsule assert capsule["interaction_contract"]["mode"] != "scoped_user_gate_fallback" assert capsule["execution_obligation"]["must_attempt_work"] is False assert extract_turn_authority({"turn_envelope": envelope})["write_scope"] == [] diff --git a/tests/control_plane_ts/protocol_action_packet_compat.test.ts b/tests/control_plane_ts/protocol_action_packet_compat.test.ts new file mode 100644 index 0000000000..c097dff6cf --- /dev/null +++ b/tests/control_plane_ts/protocol_action_packet_compat.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { interpretQuotaShouldRunPacket, type JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import { + buildTurnEnvelope, + quotaActionSignatureDocument, + turnEnvelopeActionSignatureDocument, +} from "../../loopx/control_plane/quota/turn_envelope.ts"; + +// Synthetic v0 input using the ordered wire fields introduced by PR #1898 +// (fa1a7099400841568d082abc1f60b410311c8945, interaction_contract.py). +// Do not derive this input from the current packet writer. The summary is +// observational: it cannot override the typed action or confer spend authority. +const historicalSummary = "actor=agent user_action_required=false agent_action_required=true " + + "quiet_noop_allowed=false llm=no_api agent_action=inspect the fixture"; +const semanticFields = { + actor: "agent", user_action_required: false, agent_action_required: true, + quiet_noop_allowed: false, llm: "no_api", agent_action: "inspect the fixture", +}; + +function decision(summary?: string): JsonObject { + return { + ok: true, goal_id: "compat-goal", agent_identity: { agent_id: "compat-agent" }, + decision: "run", should_run: true, effective_action: "normal_run", + 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, + primary_action: "inspect the fixture", + }, + cli_channel: { + next_cli_actions: ["loopx status --goal-id compat-goal"], + spend_allowed_now: false, spend_after_validation: true, + }, + }, + ...(summary === undefined ? {} : { + protocol_action_packet: { schema_version: "protocol_action_packet_v0", summary }, + }), + }; +} + +for (const [name, summary, status] of [ + ["absent", undefined, undefined], + ["historical-v0", historicalSummary, "verified"], + ["opaque-v0", "legacy opaque packet", "unverified_retain_summary"], +] as const) { + test(`${name}: the real TS reader preserves actions and the envelope witness`, () => { + const source = decision(summary); + const before = structuredClone(source); + const turn = interpretQuotaShouldRunPacket(source); + assert.equal(turn.observation.protocol_summary, summary ?? null); + assert.equal(turn.observation.effective_action, "normal_run"); + assert.equal(turn.observation.should_run, true); + assert.deepEqual(turn.next_effect.cli_actions, ["loopx status --goal-id compat-goal"]); + + const envelope = buildTurnEnvelope({ + payload: source, protocol_action_fields: semanticFields, scheduler_execution_args: "", + }); + assert.equal((envelope.action as JsonObject).primary_action, "inspect the fixture"); + assert.equal((envelope.writeback as JsonObject).spend_allowed_now, false); + const witness = (envelope.contract_capsule as JsonObject).protocol_action_packet as JsonObject | undefined; + if (status === undefined) { + assert.equal(witness, undefined); + } else { + assert.equal(witness?.derivation_status, status); + assert.equal(witness?.reconstruction_verified, name === "historical-v0"); + assert.equal(witness?.summary, name === "opaque-v0" ? summary : undefined); + } + const signed = quotaActionSignatureDocument(source, semanticFields); + assert.deepEqual(turnEnvelopeActionSignatureDocument(envelope), signed); + // Hash labels are diagnostic input, not the semantic document. Tampering + // must change the real reader's document even if matches stays true. + (envelope.writeback as JsonObject).spend_allowed_now = true; + assert.equal((envelope.action_signature as JsonObject).matches, true); + assert.notDeepEqual(turnEnvelopeActionSignatureDocument(envelope), signed); + assert.deepEqual(source, before); + }); +} + +test("v0 residue preserves the old witness without replacing the current action", () => { + const source = decision(historicalSummary); + ((source.interaction_contract as JsonObject).agent_channel as JsonObject).primary_action = "inspect the updated fixture"; + const envelope = buildTurnEnvelope({ + payload: source, protocol_action_fields: semanticFields, scheduler_execution_args: "", + }); + const witness = (envelope.contract_capsule as JsonObject).protocol_action_packet as JsonObject; + assert.equal((envelope.action as JsonObject).primary_action, "inspect the updated fixture"); + assert.equal(witness.derivation_status, "verified_with_residue"); + assert.equal(witness.reconstruction_verified, true); + assert.deepEqual(witness.residue, { agent_action: "inspect the fixture" }); + assert.equal(witness.summary, undefined); + const signed = quotaActionSignatureDocument(source, semanticFields); + assert.deepEqual(turnEnvelopeActionSignatureDocument(envelope), signed); + delete witness.residue; + assert.notDeepEqual(turnEnvelopeActionSignatureDocument(envelope), signed); +}); + +test("missing semantic projection retains v0 summary as unverified fallback", () => { + const envelope = buildTurnEnvelope({ + payload: decision(historicalSummary), protocol_action_fields: {}, scheduler_execution_args: "", + }); + const witness = (envelope.contract_capsule as JsonObject).protocol_action_packet as JsonObject; + assert.equal(witness.derivation_status, "unverified_retain_summary"); + assert.equal(witness.reconstruction_verified, false); + assert.equal(witness.summary, historicalSummary); + assert.equal((envelope.action as JsonObject).primary_action, "inspect the fixture"); + assert.equal((envelope.writeback as JsonObject).spend_allowed_now, false); +}); diff --git a/tests/fixtures/protocol_packet_history_v0.json b/tests/fixtures/protocol_packet_history_v0.json new file mode 100644 index 0000000000..638077c028 --- /dev/null +++ b/tests/fixtures/protocol_packet_history_v0.json @@ -0,0 +1,602 @@ +{ + "schema_version": "protocol_packet_history_fixture_v0", + "source": { + "release": "v1.1.0", + "git_sha": "607c11d75e9d608e44d9caf6b675b32c0abb8267", + "synthetic": true, + "scope": "Four synthetic compatibility samples generated with the release implementation; not an external archive or evidence of archive completeness. No real user data, credentials, local paths or logs.", + "generation": [ + "Run from the exact source SHA above using uv run --extra test python with Node 22.22.3 on PATH (generation Python: 3.12.3).", + "Decisions are small synthetic input objects, not captured live quota decisions. For verified and residue, build_protocol_action_packet(decision) from loopx.control_plane.work_items.interaction_contract produced the stored packet.", + "The residue input intentionally has different agent_lane_next_action.text and interaction_contract.agent_channel.primary_action before packet generation.", + "For opaque, replace the generated packet summary with the stored synthetic opaque observation before building the envelope. For missing_packet, omit protocol_action_packet and use the stored non-delivery decision.", + "For every stored decision, loopx.control_plane.quota.turn_envelope.build_turn_envelope(decision) produced the entire stored envelope, unchanged including action_signature and compaction.", + "Release quota_action_signature_document(decision) equalled turn_envelope_action_signature_document(envelope), and extract_turn_authority accepted each envelope.", + "Reproduce only in the pinned release: rebuild packets for verified/residue and build_turn_envelope(case[\"decision\"]) for every case; compare whole objects. Current tests read frozen inputs and never rebuild envelopes." + ], + "signature_boundary": "action_signature is the release SHA-256 semantic integrity witness, not a keyed signature or issuer authentication. Rejection tests mutate copies without recomputing this witness." + }, + "cases": [ + { + "id": "verified", + "decision": { + "ok": true, + "goal_id": "history-fixture-goal", + "agent_identity": { + "agent_id": "history-fixture-agent" + }, + "decision": "run", + "should_run": true, + "effective_action": "normal_run", + "state": "eligible", + "normal_delivery_allowed": true, + "safe_bypass_allowed": false, + "agent_lane_next_action": { + "text": "inspect the synthetic fixture" + }, + "execution_obligation": { + "schema_version": "execution_obligation_v0", + "must_attempt_work": true + }, + "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, + "primary_action": "inspect the synthetic fixture" + }, + "cli_channel": { + "next_cli_actions": [ + "loopx status --goal-id history-fixture-goal" + ], + "spend_allowed_now": false, + "spend_after_validation": true + } + }, + "goal_boundary": { + "write_scope": [ + "tests/**" + ], + "rule": "stay_in_scope_or_stop" + }, + "protocol_action_packet": { + "schema_version": "protocol_action_packet_v0", + "summary": "actor=agent user_action_required=false agent_action_required=true quiet_noop_allowed=false llm=no_api agent_action=inspect the synthetic fixture" + } + }, + "envelope": { + "ok": true, + "schema_version": "loopx_turn_envelope_v0", + "mode": "should-run", + "view": "turn_envelope", + "goal_id": "history-fixture-goal", + "agent_id": "history-fixture-agent", + "reason": null, + "action_required": false, + "open_count": 0, + "decision": "run", + "should_run": true, + "effective_action": "normal_run", + "state": "eligible", + "action": { + "recommended_action": null, + "primary_action": "inspect the synthetic fixture", + "must_attempt": true, + "delivery_allowed": true, + "quiet_noop_allowed": false, + "selected_todo": null + }, + "user": { + "action_required": false, + "open_count": 0, + "notify": "DONT_NOTIFY" + }, + "required_reads": [], + "replan_action_packet": null, + "boundary": { + "rule": "stay_in_scope_or_stop", + "write_scope": [ + "tests/**" + ] + }, + "execution_policy": { + "normal_delivery_allowed": true, + "safe_bypass_allowed": false + }, + "writeback": { + "spend_allowed_now": false, + "spend_after_validation": true, + "spend_policy": null, + "next_cli_actions": [ + "loopx status --goal-id history-fixture-goal" + ] + }, + "scheduler": {}, + "contract_capsule": { + "schema_version": "loopx_contract_capsule_v0", + "source": "full_quota_decision", + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "bounded_delivery" + }, + "execution_obligation": { + "must_attempt_work": true + }, + "protocol_action_packet": { + "schema_version": "protocol_action_packet_v0", + "present": true, + "summary_hash": "sha256:cee7662ce044f025956e4d31f354fdfd6465fc1e3bd83d2146b4d13f35495e2b", + "derivation_status": "verified", + "reconstruction_verified": true, + "llm_policy": "no_api", + "candidate_derivation_inputs": [ + "action", + "user", + "work_lane_contract", + "automation_liveness", + "scheduler" + ] + } + }, + "detail_ref": { + "full_decision": "rerun the typed quota_guard from the current host packet", + "todo_detail": "loopx --format json todo list --goal-id history-fixture-goal", + "status_detail": "loopx --format json status --goal-id history-fixture-goal" + }, + "action_signature": { + "schema_version": "loopx_action_signature_v0", + "coverage": "turn_envelope_action_dimensions_v0", + "source_hash": "sha256:521298acf627470667f3db8a6d031139695038e4588b5a15904dcc929258238d", + "envelope_hash": "sha256:521298acf627470667f3db8a6d031139695038e4588b5a15904dcc929258238d", + "matches": true, + "source_decision_hash": "sha256:4fbc9096921e75acc58a5bf79fd5bb6c14beb866a2fcf185f6c3ddf4237387c7" + }, + "compaction": { + "source_json_bytes": 1148, + "envelope_json_bytes": 2346, + "byte_reduction_ratio": -1.0436, + "budget_bytes": 8192, + "within_budget": true, + "envelope_utf8_bytes": 2346 + } + } + }, + { + "id": "residue", + "decision": { + "ok": true, + "goal_id": "history-fixture-goal", + "agent_identity": { + "agent_id": "history-fixture-agent" + }, + "decision": "run", + "should_run": true, + "effective_action": "normal_run", + "state": "eligible", + "normal_delivery_allowed": true, + "safe_bypass_allowed": false, + "agent_lane_next_action": { + "text": "inspect the synthetic fixture" + }, + "execution_obligation": { + "schema_version": "execution_obligation_v0", + "must_attempt_work": true + }, + "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, + "primary_action": "inspect the updated synthetic fixture" + }, + "cli_channel": { + "next_cli_actions": [ + "loopx status --goal-id history-fixture-goal" + ], + "spend_allowed_now": false, + "spend_after_validation": true + } + }, + "goal_boundary": { + "write_scope": [ + "tests/**" + ], + "rule": "stay_in_scope_or_stop" + }, + "protocol_action_packet": { + "schema_version": "protocol_action_packet_v0", + "summary": "actor=agent user_action_required=false agent_action_required=true quiet_noop_allowed=false llm=no_api agent_action=inspect the synthetic fixture" + } + }, + "envelope": { + "ok": true, + "schema_version": "loopx_turn_envelope_v0", + "mode": "should-run", + "view": "turn_envelope", + "goal_id": "history-fixture-goal", + "agent_id": "history-fixture-agent", + "reason": null, + "action_required": false, + "open_count": 0, + "decision": "run", + "should_run": true, + "effective_action": "normal_run", + "state": "eligible", + "action": { + "recommended_action": null, + "primary_action": "inspect the updated synthetic fixture", + "must_attempt": true, + "delivery_allowed": true, + "quiet_noop_allowed": false, + "selected_todo": null + }, + "user": { + "action_required": false, + "open_count": 0, + "notify": "DONT_NOTIFY" + }, + "required_reads": [], + "replan_action_packet": null, + "boundary": { + "rule": "stay_in_scope_or_stop", + "write_scope": [ + "tests/**" + ] + }, + "execution_policy": { + "normal_delivery_allowed": true, + "safe_bypass_allowed": false + }, + "writeback": { + "spend_allowed_now": false, + "spend_after_validation": true, + "spend_policy": null, + "next_cli_actions": [ + "loopx status --goal-id history-fixture-goal" + ] + }, + "scheduler": {}, + "contract_capsule": { + "schema_version": "loopx_contract_capsule_v0", + "source": "full_quota_decision", + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "bounded_delivery" + }, + "execution_obligation": { + "must_attempt_work": true + }, + "protocol_action_packet": { + "schema_version": "protocol_action_packet_v0", + "present": true, + "summary_hash": "sha256:cee7662ce044f025956e4d31f354fdfd6465fc1e3bd83d2146b4d13f35495e2b", + "derivation_status": "verified_with_residue", + "reconstruction_verified": true, + "llm_policy": "no_api", + "candidate_derivation_inputs": [ + "action", + "user", + "work_lane_contract", + "automation_liveness", + "scheduler" + ], + "residue": { + "agent_action": "inspect the synthetic fixture" + } + } + }, + "detail_ref": { + "full_decision": "rerun the typed quota_guard from the current host packet", + "todo_detail": "loopx --format json todo list --goal-id history-fixture-goal", + "status_detail": "loopx --format json status --goal-id history-fixture-goal" + }, + "action_signature": { + "schema_version": "loopx_action_signature_v0", + "coverage": "turn_envelope_action_dimensions_v0", + "source_hash": "sha256:258fe4ee1b4f37426e3a89b760acff8068bd336dfcfbc1499bb7651f598d922f", + "envelope_hash": "sha256:258fe4ee1b4f37426e3a89b760acff8068bd336dfcfbc1499bb7651f598d922f", + "matches": true, + "source_decision_hash": "sha256:4a293a42c26943ac7e57235e4b62dd43da48a2a75c787ad8e678e9804cd0e161" + }, + "compaction": { + "source_json_bytes": 1156, + "envelope_json_bytes": 2426, + "byte_reduction_ratio": -1.0986, + "budget_bytes": 8192, + "within_budget": true, + "envelope_utf8_bytes": 2426 + } + } + }, + { + "id": "opaque", + "decision": { + "ok": true, + "goal_id": "history-fixture-goal", + "agent_identity": { + "agent_id": "history-fixture-agent" + }, + "decision": "run", + "should_run": true, + "effective_action": "normal_run", + "state": "eligible", + "normal_delivery_allowed": true, + "safe_bypass_allowed": false, + "agent_lane_next_action": { + "text": "inspect the synthetic fixture" + }, + "execution_obligation": { + "schema_version": "execution_obligation_v0", + "must_attempt_work": true + }, + "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, + "primary_action": "inspect the synthetic fixture" + }, + "cli_channel": { + "next_cli_actions": [ + "loopx status --goal-id history-fixture-goal" + ], + "spend_allowed_now": false, + "spend_after_validation": true + } + }, + "goal_boundary": { + "write_scope": [ + "tests/**" + ], + "rule": "stay_in_scope_or_stop" + }, + "protocol_action_packet": { + "schema_version": "protocol_action_packet_v0", + "summary": "synthetic opaque historical observation" + } + }, + "envelope": { + "ok": true, + "schema_version": "loopx_turn_envelope_v0", + "mode": "should-run", + "view": "turn_envelope", + "goal_id": "history-fixture-goal", + "agent_id": "history-fixture-agent", + "reason": null, + "action_required": false, + "open_count": 0, + "decision": "run", + "should_run": true, + "effective_action": "normal_run", + "state": "eligible", + "action": { + "recommended_action": null, + "primary_action": "inspect the synthetic fixture", + "must_attempt": true, + "delivery_allowed": true, + "quiet_noop_allowed": false, + "selected_todo": null + }, + "user": { + "action_required": false, + "open_count": 0, + "notify": "DONT_NOTIFY" + }, + "required_reads": [], + "replan_action_packet": null, + "boundary": { + "rule": "stay_in_scope_or_stop", + "write_scope": [ + "tests/**" + ] + }, + "execution_policy": { + "normal_delivery_allowed": true, + "safe_bypass_allowed": false + }, + "writeback": { + "spend_allowed_now": false, + "spend_after_validation": true, + "spend_policy": null, + "next_cli_actions": [ + "loopx status --goal-id history-fixture-goal" + ] + }, + "scheduler": {}, + "contract_capsule": { + "schema_version": "loopx_contract_capsule_v0", + "source": "full_quota_decision", + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "bounded_delivery" + }, + "execution_obligation": { + "must_attempt_work": true + }, + "protocol_action_packet": { + "schema_version": "protocol_action_packet_v0", + "present": true, + "summary_hash": "sha256:687a3aaccc05c93858d47a0e4575dca02c3fdb4ac572bc870358517e5541a268", + "derivation_status": "unverified_retain_summary", + "reconstruction_verified": false, + "llm_policy": "no_api", + "candidate_derivation_inputs": [ + "action", + "user", + "work_lane_contract", + "automation_liveness", + "scheduler" + ], + "summary": "synthetic opaque historical observation" + } + }, + "detail_ref": { + "full_decision": "rerun the typed quota_guard from the current host packet", + "todo_detail": "loopx --format json todo list --goal-id history-fixture-goal", + "status_detail": "loopx --format json status --goal-id history-fixture-goal" + }, + "action_signature": { + "schema_version": "loopx_action_signature_v0", + "coverage": "turn_envelope_action_dimensions_v0", + "source_hash": "sha256:151ad9052e06cec9bca04fa59dc3906d65c9636fcbfa1deebbface0a619ad779", + "envelope_hash": "sha256:151ad9052e06cec9bca04fa59dc3906d65c9636fcbfa1deebbface0a619ad779", + "matches": true, + "source_decision_hash": "sha256:4fe3dab3a8da7c7ff0778ed3bd3a696e5d6c2113efc946455e07fef6521fe226" + }, + "compaction": { + "source_json_bytes": 1043, + "envelope_json_bytes": 2416, + "byte_reduction_ratio": -1.3164, + "budget_bytes": 8192, + "within_budget": true, + "envelope_utf8_bytes": 2416 + } + } + }, + { + "id": "missing_packet", + "decision": { + "ok": true, + "goal_id": "history-fixture-goal", + "agent_identity": { + "agent_id": "history-fixture-agent" + }, + "decision": "skip", + "should_run": false, + "effective_action": "quota_skip", + "state": "eligible", + "normal_delivery_allowed": false, + "safe_bypass_allowed": false, + "agent_lane_next_action": { + "text": "inspect the synthetic fixture" + }, + "execution_obligation": { + "schema_version": "execution_obligation_v0", + "must_attempt_work": false + }, + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "skip", + "user_channel": { + "action_required": false, + "notify": "DONT_NOTIFY" + }, + "agent_channel": { + "must_attempt": false, + "delivery_allowed": false, + "quiet_noop_allowed": true, + "primary_action": "quiet no-op; no material transition" + }, + "cli_channel": { + "next_cli_actions": [], + "spend_allowed_now": false, + "spend_after_validation": false + } + }, + "goal_boundary": { + "write_scope": [ + "tests/**" + ], + "rule": "stay_in_scope_or_stop" + } + }, + "envelope": { + "ok": true, + "schema_version": "loopx_turn_envelope_v0", + "mode": "should-run", + "view": "turn_envelope", + "goal_id": "history-fixture-goal", + "agent_id": "history-fixture-agent", + "reason": null, + "action_required": false, + "open_count": 0, + "decision": "skip", + "should_run": false, + "effective_action": "quota_skip", + "state": "eligible", + "action": { + "recommended_action": null, + "primary_action": "quiet no-op; no material transition", + "must_attempt": false, + "delivery_allowed": false, + "quiet_noop_allowed": true, + "selected_todo": null + }, + "user": { + "action_required": false, + "open_count": 0, + "notify": "DONT_NOTIFY" + }, + "required_reads": [], + "replan_action_packet": null, + "boundary": { + "rule": "stay_in_scope_or_stop", + "write_scope": [ + "tests/**" + ] + }, + "execution_policy": { + "normal_delivery_allowed": false, + "safe_bypass_allowed": false + }, + "writeback": { + "spend_allowed_now": false, + "spend_after_validation": false, + "spend_policy": null, + "next_cli_actions": [] + }, + "scheduler": {}, + "contract_capsule": { + "schema_version": "loopx_contract_capsule_v0", + "source": "full_quota_decision", + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "skip" + }, + "execution_obligation": { + "must_attempt_work": false + } + }, + "detail_ref": { + "full_decision": "rerun the typed quota_guard from the current host packet", + "todo_detail": "loopx --format json todo list --goal-id history-fixture-goal", + "status_detail": "loopx --format json status --goal-id history-fixture-goal" + }, + "action_signature": { + "schema_version": "loopx_action_signature_v0", + "coverage": "turn_envelope_action_dimensions_v0", + "source_hash": "sha256:6ce6e77fd49ac9971276f3c106c50e1b79368f6fafa966482a23c27e7ef0f0c4", + "envelope_hash": "sha256:6ce6e77fd49ac9971276f3c106c50e1b79368f6fafa966482a23c27e7ef0f0c4", + "matches": true, + "source_decision_hash": "sha256:2f2451e136ad1f28f5322879447d8ac95fe9289fd31003394d1c9b95c65648dc" + }, + "compaction": { + "source_json_bytes": 874, + "envelope_json_bytes": 1937, + "byte_reduction_ratio": -1.2162, + "budget_bytes": 8192, + "within_budget": true, + "envelope_utf8_bytes": 1937 + } + } + } + ] +} diff --git a/tests/test_turn_envelope.py b/tests/test_turn_envelope.py index 3d703d74da..3dd609e955 100644 --- a/tests/test_turn_envelope.py +++ b/tests/test_turn_envelope.py @@ -22,8 +22,10 @@ scheduler_execution_context_for_runtime_profile, ) from loopx.control_plane.work_items.interaction_contract import ( - build_protocol_action_packet, + protocol_action_packet_fields, + render_protocol_action_packet_summary, ) +from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority STATE_MATRIX = json.loads( @@ -32,6 +34,50 @@ ) ) +# Synthetic bounded-delivery example of the v0 wire format in PR #1898, +# fa1a7099400841568d082abc1f60b410311c8945: +# work_items/interaction_contract.py::protocol_action_packet_fields and +# tests/test_turn_envelope.py::test_protocol_packet_derivation_*. +# Literal input, never regenerated by the writer being tested. This is format +# evidence, not a production archive or approval of a retirement window. +HISTORICAL_V0_SUMMARY = ( + "actor=agent user_action_required=false agent_action_required=true " + "quiet_noop_allowed=false lane=advancement_task automation=execute_bounded_work " + "scheduler=run_now pause_allowed=false llm=no_api " + "agent_action=advance one product-path slice" +) + + +def _legacy_protocol_packet(source: dict[str, Any]) -> dict[str, Any]: + """Build legacy-only fixture input; live quota writers no longer emit it. + + The independently authored HISTORICAL_V0_SUMMARY exercises compatibility + separately, so this adapter is not the historical correctness oracle. + """ + return { + "schema_version": "protocol_action_packet_v0", + "summary": render_protocol_action_packet_summary(protocol_action_packet_fields(source)), + } + + +def _compat_decision(packet_format: str) -> dict[str, Any]: + source = _full_decision() + source.pop("protocol_action_packet") + if packet_format != "absent": + source["protocol_action_packet"] = { + "schema_version": "protocol_action_packet_v0", + "summary": ( + "legacy opaque packet" + if packet_format == "opaque" + else HISTORICAL_V0_SUMMARY + ), + } + if packet_format == "residue": + source["interaction_contract"]["agent_channel"]["primary_action"] = ( + "a newer canonical action projection" + ) + return source + def _deep_update(target: dict[str, Any], patch: dict[str, Any]) -> None: for key, value in patch.items(): @@ -193,7 +239,7 @@ def _full_decision() -> dict[str, object]: "goal_frontier_projection": {"large_diagnostic_lane": ["y" * 4_000]}, "plan_summary": {"large_diagnostic_lane": ["z" * 4_000]}, } - source["protocol_action_packet"] = build_protocol_action_packet(source) + source["protocol_action_packet"] = _legacy_protocol_packet(source) return source @@ -207,7 +253,7 @@ def test_turn_envelope_state_matrix_preserves_parity_and_budget( ) -> None: source = _full_decision() _deep_update(source, case["patch"]) - source["protocol_action_packet"] = build_protocol_action_packet(source) + source["protocol_action_packet"] = _legacy_protocol_packet(source) envelope = build_turn_envelope(source) @@ -761,7 +807,7 @@ def test_turn_envelope_stays_actionable_during_scheduler_reset() -> None: codex_app = source["scheduler_hint"]["codex_app"] codex_app["ack_hint"]["cli_args"] = ack_cli_args codex_app["failure_hint"] = {"cli_args": failure_cli_args} - source["protocol_action_packet"] = build_protocol_action_packet(source) + source["protocol_action_packet"] = _legacy_protocol_packet(source) envelope = build_turn_envelope(source) compact_app = envelope["scheduler"]["codex_app"] @@ -840,10 +886,7 @@ def test_action_signature_detects_semantic_drift() -> None: def test_protocol_packet_derivation_keeps_only_real_residue() -> None: - source = _full_decision() - source["interaction_contract"]["agent_channel"]["primary_action"] = ( - "a newer canonical action projection" - ) + source = _compat_decision("residue") envelope = build_turn_envelope(source) packet = envelope["contract_capsule"]["protocol_action_packet"] @@ -856,8 +899,7 @@ def test_protocol_packet_derivation_keeps_only_real_residue() -> None: def test_protocol_packet_derivation_retains_unverified_summary() -> None: - source = _full_decision() - source["protocol_action_packet"]["summary"] = "legacy opaque packet" + source = _compat_decision("opaque") envelope = build_turn_envelope(source) packet = envelope["contract_capsule"]["protocol_action_packet"] @@ -867,6 +909,139 @@ def test_protocol_packet_derivation_retains_unverified_summary() -> None: assert packet["summary"] == "legacy opaque packet" +@pytest.mark.parametrize("packet_format", ["absent", "historical_v0", "opaque", "residue"]) +def test_protocol_packet_compatibility_through_real_readers(packet_format: str) -> None: + """The summary is an observation; the typed decision owns execution.""" + source = _compat_decision(packet_format) + before = deepcopy(source) + turn = interpret_quota_should_run_packet(source) + envelope = build_turn_envelope(source) + authority = extract_turn_authority({"turn_envelope": envelope}) + + assert source == before # Reading does not rewrite the input packet. + assert turn.observation.effective_action == "normal_run" + assert turn.observation.should_run is True + assert turn.interpretation.route == "advancement_task" + assert list(turn.next_effect.cli_actions) == [ + "loopx refresh-state --goal-id fixture-goal --classification validated", + "loopx quota spend-slot --goal-id fixture-goal --execute", + ] + assert turn.observation.protocol_summary == ( + None if packet_format == "absent" else source["protocol_action_packet"]["summary"] + ) + assert authority["primary_action"] == ( + "a newer canonical action projection" + if packet_format == "residue" else "advance one product-path slice" + ) + assert authority["write_scope"] == ["loopx/**", "tests/**"] + assert envelope["writeback"]["spend_allowed_now"] is False + assert envelope["writeback"]["spend_after_validation"] is True + assert envelope["execution_policy"]["safe_bypass_allowed"] is False + assert quota_action_signature_document(source) == turn_envelope_action_signature_document(envelope) + capsule = envelope["contract_capsule"] + if packet_format == "absent": + assert "protocol_action_packet" not in capsule + else: + assert capsule["protocol_action_packet"]["derivation_status"] == { + "historical_v0": "verified", + "opaque": "unverified_retain_summary", + "residue": "verified_with_residue", + }[packet_format] + + +def test_missing_packet_keeps_the_semantic_projection_and_signed_obligations() -> None: + source = _compat_decision("absent") + # This independently specified v0 wire example also exercises ordering. + fields = protocol_action_packet_fields(source) + assert fields["agent_action_required"] is True + assert fields["quiet_noop_allowed"] is False + assert render_protocol_action_packet_summary(fields) == HISTORICAL_V0_SUMMARY + with_packet = _compat_decision("historical_v0") + assert _legacy_protocol_packet(with_packet)["summary"] == HISTORICAL_V0_SUMMARY + without_packet = build_turn_envelope(source) + with_packet_envelope = build_turn_envelope(with_packet) + # The only removed signed dimension is the capsule's packet witness. + expected = turn_envelope_action_signature_document(with_packet_envelope) + expected["contract_capsule"].pop("protocol_action_packet") + assert turn_envelope_action_signature_document(without_packet) == expected + + +@pytest.mark.parametrize("user_required", [False, True], ids=["quiet-wait", "user-gate"]) +def test_missing_protocol_packet_cannot_reopen_a_non_delivery_decision(user_required: bool) -> None: + source = _compat_decision("absent") + source.update( + should_run=False, normal_delivery_allowed=False, + effective_action="quota_skip", decision="skip", + action_required=user_required, open_count=int(user_required), + ) + source["execution_obligation"]["must_attempt_work"] = False + source["work_lane_contract"]["must_attempt_work"] = False + interaction = source["interaction_contract"] + interaction["user_channel"].update( + action_required=user_required, notify="NOTIFY" if user_required else "DONT_NOTIFY", + ) + interaction["agent_channel"].update( + must_attempt=False, delivery_allowed=False, quiet_noop_allowed=not user_required, + ) + interaction["cli_channel"].update( + next_cli_actions=[], spend_allowed_now=False, spend_after_validation=False, + ) + envelope = build_turn_envelope(source) + turn = interpret_quota_should_run_packet(source) + assert turn.observation.should_run is False + assert turn.observation.protocol_summary is None + assert not turn.next_effect.cli_actions + assert envelope["action"]["must_attempt"] is False + assert envelope["action"]["delivery_allowed"] is False + assert envelope["user"]["action_required"] is user_required + assert envelope["action"]["quiet_noop_allowed"] is (not user_required) + assert envelope["writeback"]["spend_after_validation"] is False + assert envelope["writeback"]["spend_allowed_now"] is False + + +@pytest.mark.parametrize("packet_format", ["absent", "historical_v0", "opaque", "residue"]) +@pytest.mark.parametrize("corruption", ["missing", "mismatch", "forged_equal_hashes", "action", "capsule"]) +def test_protocol_packet_compatibility_does_not_bypass_host_signature_check( + packet_format: str, corruption: str, +) -> None: + envelope = build_turn_envelope(_compat_decision(packet_format)) + if corruption == "missing": + envelope.pop("action_signature") + elif corruption == "mismatch": + envelope["action_signature"]["matches"] = False + elif corruption == "forged_equal_hashes": + envelope["action_signature"].update( + source_hash="sha256:" + "0" * 64, envelope_hash="sha256:" + "0" * 64, + ) + elif corruption == "action": + envelope["action"]["primary_action"] = "perform an unsigned replacement action" + else: + envelope["contract_capsule"]["protocol_action_packet"] = { + "schema_version": "protocol_action_packet_v0", "summary": "unsigned witness", + } + with pytest.raises(ValueError, match="action signature is missing or does not match"): + extract_turn_authority({"turn_envelope": envelope}) + + +@pytest.mark.parametrize("has_packet", [False, True]) +def test_envelope_fallback_preserves_typed_failure_with_or_without_packet(has_packet: bool) -> None: + from loopx.cli_commands.quota import _render_turn_envelope_payload + + failure: dict[str, Any] = { + "ok": False, "decision": "skip", "should_run": False, + "error_code": "scheduler_execution_context_invalid", + } + if has_packet: + failure["protocol_action_packet"] = { + "schema_version": "protocol_action_packet_v0", "summary": HISTORICAL_V0_SUMMARY, + } + before = deepcopy(failure) + rendered = _render_turn_envelope_payload(failure, None) + assert "interaction_contract must be an object" in rendered.pop("turn_envelope_skipped") + assert rendered == before + assert failure == before + + def test_protocol_packet_monitor_action_derives_without_residue() -> None: source = _full_decision() source["execution_obligation"]["must_attempt_work"] = False @@ -886,7 +1061,7 @@ def test_protocol_packet_monitor_action_derives_without_residue() -> None: "must_attempt_work": False, } ) - source["protocol_action_packet"] = build_protocol_action_packet(source) + source["protocol_action_packet"] = _legacy_protocol_packet(source) envelope = build_turn_envelope(source) packet = envelope["contract_capsule"]["protocol_action_packet"]