diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md index 5cb3ea4a1..d68049fe0 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md @@ -166,3 +166,41 @@ decision completeness and existing drill-down contracts at their shared typed owner; do not infer that backend switching alone fixes these costs. A/C still need integrated execution/adoption evidence, and no D2 elapsed soak starts or legacy-writer deletion is certified by this follow-up. + +### Read-cost qualification update + +After #4931 and #5215 integrated, matched detached File/SQLite copies retained +379 original commits and the same final projection hash. On Node 24.21.0, +three fresh processes per provider measured File head reads at 5.98–6.32 s +versus SQLite at 34.5–36.0 ms; repeated reads were 9.1–10.2 ms and 25.7–28.2 ms +respectively. This is process-cold, not OS-cache-cold: File proves its entire +retained journal, whereas SQLite reads current state without making the same +full-history proof. It is evidence for a long-history SQLite candidate, not +equivalent integrity-work throughput or release-default acceptance. + +Alternating two unchanged File stores exposed singleton proof-cache eviction: +every read cost 6.30–6.49 s. A bounded four-store working set keeps the first +proof for each store (6.15–6.16 s) and subsequent alternation at 9.8–11.2 ms, +with identical cursors/hashes. Exact-byte and identity checks remain mandatory; +eviction and corruption regressions cover the changed cache boundary. + +Quota observation reused the existing should-run compactors: a captured single +Goal row serialized from 1,252,747 to 78,688 UTF-8 bytes, with explicit full +detail restoring the original row. This is a display measurement; collection, +decision inputs and first-read verification are not reduced by it. + +A separate 148-second isolated run appended 12 commits per provider through +fresh processes, crossing a checkpoint and checking original-receipt replay, +changed-intent rejection and projection/hash parity at every step. It qualifies +that bounded storage journey, **not** Host execution, live Goal adoption or D2's +ten-day soak. No active authority, release default or legacy-writer deletion +decision changes. B still needs sustained workload/platform/capacity evidence; +C still needs consumer/onboarding and supported upgrade acceptance. + +The next B slice is the remaining whole-command cold path: profile history +artifact lookup, active-contract validation and public-boundary scanning on +the same retained inputs before selecting the owning repair. Separate provider +head-read time from caller work; preserve freshness, full decision inputs and +corruption rejection. Re-run installed CLI consumers after integration. Do not +count this read optimization as closing A/C or use a fixed remaining-PR estimate; +retire a writer only with its last supported caller and recovery acceptance. diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md index 7a1e91a61..7a1538b9f 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md @@ -129,3 +129,33 @@ settlement 链路验收。 history,status/quota 仍可能生成数 MB 诊断包。在现有共享 typed owner 保留决策 完整性与 drill-down 合同,不能推断换后端就能消除这些成本。A/C 仍需执行/采用集成 证据;本轮没有启动 D2 自然时间 soak,也没有认证旧 writer 可以删除。 + +### 读取成本验收更新 + +#4931、#5215 集成后,配对的 File/SQLite 隔离副本保留 379 笔原始提交,最终 +projection hash 相同。Node 24.21.0 下,每个 provider 分别启动三个新进程, +File 首次 head 读取为 5.98–6.32 秒,SQLite 为 34.5–36.0 毫秒;后续读取分别为 +9.1–10.2 毫秒、25.7–28.2 毫秒。这是进程冷读,没有清空 OS 文件缓存;File +验证全部保留历史,SQLite 读取当前状态,不承担相同的全历史证明。这支持将 SQLite +作为长历史候选,但不是同等完整性工作量的吞吐比较,也不构成发布默认值验收。 + +交替读取两个未变化的 File 存储,暴露了单份证明缓存互相淘汰的问题:每次都要 +6.30–6.49 秒。改为有总容量上限的四份缓存后,各存储首次验证仍为 6.15–6.16 秒, +后续交替读取为 9.8–11.2 毫秒,cursor/hash 相同。每次仍检查实际字节摘要和存储 +身份;淘汰与损坏回归覆盖缓存边界。 + +Quota 观察复用既有 should-run 摘要:捕获的单 Goal 行序列化由 1,252,747 降至 +78,688 UTF-8 字节,显式明细恢复原行。这是展示体积测量,未减少采集、决策输入或 +首次读取的验证成本。 + +另一项 148 秒隔离演练通过新进程为两种 provider 各追加 12 笔提交,跨越 checkpoint, +逐轮验证原回执重放、变更意图拒绝及 projection/hash 一致性。它证明这段有界存储 +流程,**不代表** Host 执行、活跃 Goal 采用或 D2 的十天 soak 已完成。本次不改变活跃 +authority、发布默认值或旧 writer 删除决定。B 仍缺持续负载/平台/容量证据;C 仍需 +consumer/新建入口及受支持升级验收。 + +B 的下一段是剩余整命令冷路径:在相同保留输入上分别分析历史 artifact 查找、 +active-contract 验证和公共边界扫描,再选择所属 owner 修复。区分 provider head +读取与调用方工作,保留 freshness、完整决策输入和损坏拒绝;集成后重跑安装态 CLI +消费者。不能把读取优化计为 A/C 完成,也不继续给固定的剩余 PR 数;只有最后受支持 +调用方退出且恢复验收通过,才能删除对应 writer。 diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index ce2c5cb61..409b9907a 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -890,6 +890,24 @@ The first screen should make it obvious why a project is quiet: ## CLI Surface +`quota status` and `quota plan` now default to bounded Todo summaries in JSON, +reusing the summaries already used by `quota should-run`. Previously these two +observation commands returned full Todo lists. Counts, quota decisions, ordering +and health remain intact; `payload_compaction` identifies omitted lists and their +detail command. Planning still consumes complete input before this CLI projection. +Consumers that read individual Todo metadata or every item must opt into detail: + +```bash +loopx --format json quota status --include-detail all +loopx --format json quota plan --include-detail agent-todos --include-detail user-todos +``` + +Keep the original registry, runtime and Goal selection when following a detail +command. `all` expands only the sections supported by that command. Detail reads +do not acquire a Turn or spend quota. Markdown plan rendering and standalone +`status`/`todo list` are unchanged. This bounds Todo-list display growth, not the +cost of gathering and verifying the input or the total number of Goals returned. + The first read-only or preview commands are: ```bash diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index deec4a362..8e69749c6 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -87,11 +87,15 @@ removed without a separately validated caller migration. | `evidence-log --thin --limit 5` | explicit-limit cold path | returned-evidence bound | referenced run-history and rollout-event artifacts | `quota should-run` uses one repeatable cold-path selector: -`--include-detail scheduler`, `agent-todos`, `user-todos`, or -`goal-boundary`; `--include-detail all` expands every section. Public docs, +`--include-detail scheduler`, `agent-todos`, `user-todos`, `vision`, or +`goal-boundary`. `quota status` and `quota plan` accept `agent-todos` and +`user-todos`; `quota monitor-poll` accepts `decisions`. +`--include-detail all` expands the selected command's sections. Public docs, emitted `detail_ref` commands, and internal callers use only this selector. -Unknown sections and selectors attached to another quota command fail before -status collection. +Unknown or unsupported sections fail before status collection, including when +combined with `all`. Status/plan summaries preserve counts and decisions and +declare omitted lists; explicit detail preserves the full Todo metadata. These +are CLI display projections after full planning, not truncated provider inputs. The canonical emitted-output inventory and current characterization ceilings live in `loopx.control_plane.testing.cli_output_budget`. Those ceilings are diff --git a/docs/reference/file-authority-state-log.md b/docs/reference/file-authority-state-log.md index 15d9b8bf8..d9df08bcc 100644 --- a/docs/reference/file-authority-state-log.md +++ b/docs/reference/file-authority-state-log.md @@ -22,8 +22,13 @@ append-only even though File atomically replaces its physical envelope. Cold reads verify every retained transaction and the final head; a valid head cannot hide a corrupt old delta or receipt. Verified pagination reconstructs at -most 63 predecessor deltas plus the requested page. The exact-byte cache remains -bounded. File still reads/hashes and rewrites one retained file: this reduces +most 63 predecessor deltas plus the requested page. The exact-byte cache retains +at most four store paths in least-recently-used order, with a shared 128 MiB +serialized history/read-view budget. A large journal can retain only its head +and receipt index (up to 16 MiB per read view); scans and writes still verify its +history. Every cache hit requires matching file digest and store identity, not +only file timestamps. These are encoded-data bounds, not a heap/RSS limit. +File still reads/hashes and rewrites one retained file: this reduces repeated data, not asymptotic growth. Cold verification can be slower. Measure upgrade, cold verification, warm reads and steady writes separately. diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 2ba1351da..dda586df6 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -20,6 +20,7 @@ from ..control_plane.capability_hooks import InteractionProjectionHookRegistration from ..control_plane.quota.cli_projection import ( compact_quota_monitor_poll_cli_payload, + compact_quota_plan_cli_payload, compact_quota_should_run_cli_payload, ) from ..control_plane.quota.effective_action import EffectiveAction @@ -318,6 +319,8 @@ def _project_quota_cli_payload( instead of masking it with a crash (issue #3687). """ if not bool(getattr(args, "turn_envelope", False)): + if args.quota_command in {"status", "plan"}: + return compact_quota_plan_cli_payload(payload, detail_sections=detail_sections) if args.quota_command == "should-run": return compact_quota_should_run_cli_payload( payload, diff --git a/loopx/cli_commands/quota_context.py b/loopx/cli_commands/quota_context.py index 6a07e710f..8192e20b2 100644 --- a/loopx/cli_commands/quota_context.py +++ b/loopx/cli_commands/quota_context.py @@ -30,8 +30,7 @@ from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status from ..turn_identity import mint_turn_instance_id, normalize_turn_instance_id from .quota_request import ( - QUOTA_MONITOR_POLL_DETAIL_SECTIONS, - QUOTA_SHOULD_RUN_DETAIL_SECTIONS, + QUOTA_COMMAND_DETAIL_SECTIONS, quota_detail_sections_from_args, validate_quota_command_request, ) @@ -124,17 +123,13 @@ def validate_quota_command_context_request( "--turn-envelope is only valid with `quota should-run`" ) requested_details = set(getattr(args, "include_details", None) or ()) - if requested_details and command not in {"should-run", "monitor-poll"}: + if requested_details and command not in QUOTA_COMMAND_DETAIL_SECTIONS: raise QuotaCommandValidationError( - "--include-detail is only valid with `quota should-run` or " - "`quota monitor-poll`" - ) - if requested_details and "all" not in requested_details: - allowed_details = set( - QUOTA_MONITOR_POLL_DETAIL_SECTIONS - if command == "monitor-poll" - else QUOTA_SHOULD_RUN_DETAIL_SECTIONS + "--include-detail is only valid with `quota status`, `quota plan`, " + "`quota should-run` or `quota monitor-poll`" ) + if requested_details: + allowed_details = {*QUOTA_COMMAND_DETAIL_SECTIONS[command], "all"} unsupported_details = sorted(requested_details - allowed_details) if unsupported_details: raise QuotaCommandValidationError( diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 9456c16b8..313c34726 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -69,7 +69,8 @@ def register_quota_command( action="append", choices=[*QUOTA_DETAIL_SECTIONS, "all"], help=( - "Include one command-specific cold-path detail section. For `quota " + "Include one command-specific cold-path detail section. Status/plan default " + "to bounded Todo summaries; use agent-todos or user-todos for full lists. For `quota " "should-run`: scheduler, agent-todos, user-todos, goal-boundary, or " "vision. For `quota monitor-poll`: decisions. Repeat for multiple " "sections or use `all`." diff --git a/loopx/cli_commands/quota_request.py b/loopx/cli_commands/quota_request.py index 59259bc93..a80764445 100644 --- a/loopx/cli_commands/quota_request.py +++ b/loopx/cli_commands/quota_request.py @@ -13,6 +13,13 @@ "vision", ) QUOTA_MONITOR_POLL_DETAIL_SECTIONS = ("decisions",) +QUOTA_PLAN_DETAIL_SECTIONS = ("agent-todos", "user-todos") +QUOTA_COMMAND_DETAIL_SECTIONS = { + "status": QUOTA_PLAN_DETAIL_SECTIONS, + "plan": QUOTA_PLAN_DETAIL_SECTIONS, + "should-run": QUOTA_SHOULD_RUN_DETAIL_SECTIONS, + "monitor-poll": QUOTA_MONITOR_POLL_DETAIL_SECTIONS, +} QUOTA_DETAIL_SECTIONS = ( *QUOTA_SHOULD_RUN_DETAIL_SECTIONS, *QUOTA_MONITOR_POLL_DETAIL_SECTIONS, @@ -178,9 +185,7 @@ def quota_detail_sections_from_args(args: argparse.Namespace) -> frozenset[str]: sections.add("scheduler") if "all" in sections: sections.update( - QUOTA_MONITOR_POLL_DETAIL_SECTIONS - if args.quota_command == "monitor-poll" - else QUOTA_SHOULD_RUN_DETAIL_SECTIONS + QUOTA_COMMAND_DETAIL_SECTIONS.get(args.quota_command, ()) ) sections.discard("all") return frozenset(sections) diff --git a/loopx/control_plane/coordination/file_authority_store.ts b/loopx/control_plane/coordination/file_authority_store.ts index 0fc1be38c..f44dbf30d 100644 --- a/loopx/control_plane/coordination/file_authority_store.ts +++ b/loopx/control_plane/coordination/file_authority_store.ts @@ -28,13 +28,15 @@ import {AuthorityJournalScan} from "./authority_journal_scan.ts"; const STORE_IDENTITY_PATTERN = /^file:[0-9a-f]{32}$/; // File retains a checkpoint/delta journal in one durable envelope. A managed Effect server -// opens a new store handle for each request. Keep one verified read view across -// handles, keyed by exact bytes and store identity. Large journals retain only +// opens a new store handle for each request. Retain a bounded working set across +// handles so alternating Goals do not evict each other on every observation. +// Every lookup still reads and hashes the full file and checks store identity. Large journals retain only // the head and receipt index in memory; commits and scans still load and verify // the complete history. This is a bounded read optimization, not a new source // of authority or a substitute for the SQLite long-goal profile. const MAX_CACHED_DOCUMENT_BYTES = 128 * 1024 * 1024; const MAX_CACHED_READ_VIEW_BYTES = 16 * 1024 * 1024; +const MAX_CACHED_STORES = 4; interface VerifiedDocument { path: string; identity: string; @@ -49,7 +51,7 @@ interface VerifiedDocument { }>; document?: FileAuthorityJournal; } -let verifiedDocument: VerifiedDocument | null = null; +const verifiedDocuments = new Map(); // Only identical immutable input bytes share in-flight verification. Failed // proofs are removed too; neither a path nor a pending promise grants authority. const pendingVerification = new Map>(); @@ -76,10 +78,22 @@ function rememberVerifiedDocument(path: string, identity: string, raw: Uint8Arra const view: VerifiedDocument = {path, identity, digest, head: document.head, providerRevision: document.provider_revision, cursor: document.cursor, receipts, document}; - verifiedDocument = raw.byteLength <= maxDocumentBytes ? view - : viewBytes <= MAX_CACHED_READ_VIEW_BYTES - ? {...view, document: undefined} - : null; + // Account for serialized history and the separate head/receipt index. This + // is a retained-byte bound, not a claim about the JS heap or process RSS. + const fullBytes = raw.byteLength + viewBytes; + const retainHistory = raw.byteLength <= maxDocumentBytes && fullBytes <= MAX_CACHED_DOCUMENT_BYTES; + const retained = retainHistory ? view : {...view, document: undefined}; + const bytes = retainHistory ? fullBytes : viewBytes; + verifiedDocuments.delete(path); + if (retainHistory || viewBytes <= MAX_CACHED_READ_VIEW_BYTES) { + verifiedDocuments.set(path, {view: retained, bytes}); + let total = [...verifiedDocuments.values()].reduce((sum, entry) => sum + entry.bytes, 0); + while (verifiedDocuments.size > MAX_CACHED_STORES || total > MAX_CACHED_DOCUMENT_BYTES) { + const oldest = verifiedDocuments.keys().next().value!; + total -= verifiedDocuments.get(oldest)!.bytes; + verifiedDocuments.delete(oldest); + } + } return view; } @@ -274,10 +288,12 @@ export class FileAuthorityStore implements AuthorityStore { const identity = knownIdentity ?? await this.readStoreIdentity(); try { const digest = documentDigest(raw); - if (verifiedDocument?.path === this.path && - verifiedDocument.identity === identity && verifiedDocument.digest === digest && - (!requireHistory || verifiedDocument.document !== undefined)) { - return verifiedDocument; + const cached = verifiedDocuments.get(this.path); + if (cached?.view.identity === identity && cached.view.digest === digest && + (!requireHistory || cached.view.document !== undefined)) { + verifiedDocuments.delete(this.path); + verifiedDocuments.set(this.path, cached); + return cached.view; } const key = JSON.stringify([this.path, identity, digest]); let proof = pendingVerification.get(key); @@ -420,7 +436,7 @@ export class FileAuthorityStore implements AuthorityStore { // A failure after rename may already have published the new bytes. // The next read must prove the actual file rather than reuse either // the previous or attempted document. - verifiedDocument = null; + verifiedDocuments.delete(this.path); return { status: "ambiguous", reason_code: "commit_outcome_unknown", diff --git a/loopx/control_plane/quota/cli_projection.py b/loopx/control_plane/quota/cli_projection.py index 10b8c4998..c5e11ec47 100644 --- a/loopx/control_plane/quota/cli_projection.py +++ b/loopx/control_plane/quota/cli_projection.py @@ -293,7 +293,9 @@ def _compact_nested_item_lists( return compact -def _compact_agent_todo_summary(summary: dict[str, Any]) -> dict[str, Any]: +def _compact_agent_todo_summary( + summary: dict[str, Any], *, detail_command: str = QUOTA_CLI_TODO_SUMMARY_DETAIL_COMMAND +) -> dict[str, Any]: compact: dict[str, Any] = {} omitted_lanes: dict[str, int] = {} for key, value in summary.items(): @@ -327,12 +329,14 @@ def _compact_agent_todo_summary(summary: dict[str, Any]) -> dict[str, Any]: if lane != "current_agent_blocker_items" or summary.get(lane) ), "omitted_lanes": omitted_lanes, - "full_detail_cold_path": QUOTA_CLI_TODO_SUMMARY_DETAIL_COMMAND, + "full_detail_cold_path": detail_command, } return compact -def _compact_user_todo_summary(summary: dict[str, Any]) -> dict[str, Any]: +def _compact_user_todo_summary( + summary: dict[str, Any], *, detail_command: str = QUOTA_CLI_USER_TODO_SUMMARY_DETAIL_COMMAND +) -> dict[str, Any]: compact: dict[str, Any] = {} omitted_lanes: dict[str, int] = {} for key, value in summary.items(): @@ -359,7 +363,7 @@ def _compact_user_todo_summary(summary: dict[str, Any]) -> dict[str, Any]: "schema_version": QUOTA_CLI_USER_TODO_SUMMARY_COMPACTION_SCHEMA_VERSION, "retained_item_lanes": sorted(_RETAINED_USER_ITEM_LANES), "omitted_lanes": omitted_lanes, - "full_detail_cold_path": QUOTA_CLI_USER_TODO_SUMMARY_DETAIL_COMMAND, + "full_detail_cold_path": detail_command, } return compact @@ -790,3 +794,28 @@ def compact_quota_should_run_cli_payload( return _promote_interaction_contract( _promote_runtime_capability_reentry(compact) ) + + +def compact_quota_plan_cli_payload( + payload: dict[str, Any], *, detail_sections: frozenset[str] = frozenset() +) -> dict[str, Any]: + """Bound read-only CLI summaries after complete planning; retain full opt-ins.""" + if payload.get("mode") not in {"status", "plan"} or not isinstance(payload.get("groups"), dict): + return payload + + def project_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for role, compact_summary in (("agent", _compact_agent_todo_summary), ("user", _compact_user_todo_summary)): + key = f"{role}_todos" + if f"{role}-todos" not in detail_sections and isinstance(row.get(key), dict): + result[key] = compact_summary(row[key], detail_command=( + f"quota {payload['mode']} --include-detail {role}-todos" + )) + return result + + result = dict(payload) + result["groups"] = {state: [project_row(row) for row in rows] + for state, rows in payload["groups"].items()} + if isinstance(payload.get("next_automatic_turn"), dict): + result["next_automatic_turn"] = project_row(payload["next_automatic_turn"]) + return result diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index c881453cc..09c271ee7 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -183,7 +183,7 @@ }, { "site": "loopx/capabilities/manager_context/__init__.py::.configure_delivery_target::codec_read:load_registry#1", - "line": 317, + "line": 321, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -191,7 +191,7 @@ }, { "site": "loopx/capabilities/manager_context/__init__.py::.configure_evidence_scope::codec_read:load_registry#1", - "line": 275, + "line": 279, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -703,7 +703,7 @@ }, { "site": "loopx/cli_commands/quota.py::._dispatch_quota_turn_start_hooks::codec_read:load_registry#1", - "line": 270, + "line": 271, "column": 37, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_quota_plan_observation_payload.py b/tests/control_plane/test_quota_plan_observation_payload.py new file mode 100644 index 000000000..c6df3768a --- /dev/null +++ b/tests/control_plane/test_quota_plan_observation_payload.py @@ -0,0 +1,121 @@ +"""Read-only plans are bounded displays with an explicit lossless detail path.""" +from __future__ import annotations + +import json +import subprocess +import sys +from copy import deepcopy +from pathlib import Path + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + +from loopx.cli_commands.quota_context import validate_quota_command_context_request +from loopx.cli_commands.quota_request import quota_detail_sections_from_args +from loopx.cli_runtime import _build_selected_parser +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.quota.cli_projection import compact_quota_plan_cli_payload +from loopx.control_plane.quota.error_codes import QuotaCommandValidationError +from loopx.control_plane.testing.canary_harness import write_fixture_registry +from loopx.presentation.renderers.quota_markdown import render_quota_markdown + +REPO = Path(__file__).resolve().parents[2] + + +def test_plan_projection_retains_decisions_health_and_input_without_mutation(): + items = [{"todo_id": str(i), "status": "open", "text": "Work", "note": "detail" * 100} for i in range(40)] + agent = {"total_count": 40, "open_count": 40, "items": items, + "first_executable_items": items[:4], "monitor_due_count": 5, + "monitor_due_items": items[:5], "blocker_count": 2} + row = {"goal_id": "goal", "quota": {"state": "eligible", "allowed_slots": 2}, + "agent_todos": agent, "user_todos": {"items": items, "open_count": 40}} + payload = {"mode": "status", "ok": False, "groups": {"eligible": [row]}, + "next_automatic_turn": row, "health_items": [{"severity": "error"}], + "summary": {"registered_goals": 1}, "status_projection_envelope": {"coverage": {"scope": "goal"}}} + original = deepcopy(payload) + compact = compact_quota_plan_cli_payload(payload) + assert payload == original + result = compact["groups"]["eligible"][0] + assert "items" not in result["agent_todos"] + assert result["agent_todos"]["open_count"] == 40 + assert result["agent_todos"]["monitor_due_count"] == 5 + assert result["agent_todos"]["blocker_count"] == 2 + assert result["agent_todos"]["payload_compaction"]["omitted_lanes"]["items"] == 40 + assert "quota status --include-detail agent-todos" == result["agent_todos"]["payload_compaction"]["full_detail_cold_path"] + assert compact["next_automatic_turn"] == result + for key in ("health_items", "summary", "status_projection_envelope", "ok"): + assert compact[key] == payload[key] + assert result["quota"] == row["quota"] + selective = compact_quota_plan_cli_payload(payload, detail_sections=frozenset({"agent-todos"})) + selected_row = selective["groups"]["eligible"][0] + assert selected_row["agent_todos"] == agent + assert "items" not in selected_row["user_todos"] + assert compact_quota_plan_cli_payload(payload, detail_sections=frozenset({"agent-todos", "user-todos"})) == original + assert compact_quota_plan_cli_payload({"mode": "should-run", "agent_todo_summary": agent}) == {"mode": "should-run", "agent_todo_summary": agent} + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_cli_compact_and_full_detail_preserve_canonical_todos(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" + state.write_text("---\nstatus: active\nwaiting_on: codex\n---\n# Example\n\n## Agent Todo\n") + write_fixture_registry(project=tmp_path, runtime_root=runtime, registry_path=registry, + goal_id="example", domain="engineering", adapter_kind="generic_project_goal_v0", + state_file=str(state), registered_agents=["worker"]) + records = [{"schema_version": "todo_item_v0", "todo_id": f"work-{i:03}", + "role": "agent" if i < 40 else "user", "status": "open" if i % 3 else "done", + "done": i % 3 == 0, "text": f"Retained work {i}", "note": "exact metadata🙂" * 100, + "archive_state": "active", "source_section": "Agent Todo" if i < 40 else "User Todo", + "index": i + 1, "task_class": "advancement_task"} for i in range(60)] + projection = build_todo_runtime_shadow_projection(goal_id="example", todos=records, leases=[], handoff_mode="soft_claim") + initialize_canonical_authority(runtime, "example", projection, state_path=state, provider=provider) + + def call(mode, *details): + process = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", "quota", mode, "--goal-id", "example", + "--scan-root", str(tmp_path), *details], cwd=REPO, text=True, capture_output=True, timeout=60) + value = json.loads(process.stdout) + assert process.returncode == 0, value + return value + + try: + for mode in ("status", "plan"): + compact, full = call(mode), call(mode, "--include-detail", "all") + compact_row = next(row for group in compact["groups"].values() for row in group) + full_row = next(row for group in full["groups"].values() for row in group) + assert compact["summary"] == full["summary"] + # Compare one observation basis: separate CLI calls legitimately carry + # different freshness timestamps, unrelated to display compaction. + assert render_quota_markdown(compact_quota_plan_cli_payload(full)) == render_quota_markdown(full) + for role, count in (("agent", 40), ("user", 20)): + c, f = compact_row[f"{role}_todos"], full_row[f"{role}_todos"] + assert c["total_count"] == f["total_count"] == count + assert "items" not in c + actual = {item["todo_id"]: item for item in f["items"]} + for record in records: + if record["role"] == role: + assert actual[record["todo_id"]]["note"] == record["note"] + assert actual[record["todo_id"]]["status"] == record["status"] + assert len(json.dumps(compact)) < len(json.dumps(full)) / 2 + assert not list((runtime / "goals" / "example" / "runs").glob("*.json*")) + finally: + assert restart_effect_runtime()["status"] in {"stopped", "not_running"} + + +def test_plan_all_only_expands_observation_sections(): + args = _build_selected_parser("quota").parse_args(["quota", "plan", "--include-detail", "all"]) + assert quota_detail_sections_from_args(args) == frozenset({"agent-todos", "user-todos"}) + + +@pytest.mark.parametrize("command,sections", [ + ("status", ["decisions"]), ("plan", ["all", "scheduler"]), + ("monitor-poll", ["all", "agent-todos"]), +]) +def test_detail_selector_rejects_foreign_sections_even_with_all(command, sections): + argv = ["quota", command] + for section in sections: + argv.extend(["--include-detail", section]) + args = _build_selected_parser("quota").parse_args(argv) + with pytest.raises(QuotaCommandValidationError, match="does not accept --include-detail"): + validate_quota_command_context_request(args) diff --git a/tests/control_plane_ts/authority_store.test.ts b/tests/control_plane_ts/authority_store.test.ts index 318743a33..bd4994c50 100644 --- a/tests/control_plane_ts/authority_store.test.ts +++ b/tests/control_plane_ts/authority_store.test.ts @@ -284,3 +284,63 @@ test("concurrent cold reads share only the same exact-byte proof and recover aft assert.equal((await readers[0]!.loadAuthority()).status, "loaded"); assert.equal(CountingStore.validations, 4); }); + +test("alternating File stores reuse their own exact-byte proofs across handles", async t => { + const fixtures = await Promise.all([fixture(t), fixture(t)]); + for (const {store} of fixtures) { + assert.equal((await store.commitAuthority(commit(null, "alternating", 1, 1))).status, "applied"); + await writeFile(store.path, (await readFile(store.path, "utf8")) + "\n"); + } + class CountingStore extends FileAuthorityStore { + static validations = 0; + protected override decodeStoredDocument(value: unknown, identity: string) { + CountingStore.validations++; + return super.decodeStoredDocument(value, identity); + } + } + for (let round = 0; round < 3; round++) { + for (const {root} of fixtures) { + const result = await new CountingStore(root, "goal-a").loadAuthority(); + assert.equal(result.status, "loaded"); + if (result.status === "loaded") { + assert.equal(result.head.authority_revision, 1); + result.head.authority_revision = "caller mutation"; + } + } + } + assert.equal(CountingStore.validations, 2, "each unchanged store proves its history once"); + const first = fixtures[0]!; + const document = JSON.parse(await readFile(first.store.path, "utf8")); + document.committed[0].provider_revision = "tampered"; + await writeFile(first.store.path, JSON.stringify(document)); + assert.equal((await new CountingStore(first.root, "goal-a").loadAuthority()).status, "failed"); + assert.equal((await new CountingStore(fixtures[1]!.root, "goal-a").loadAuthority()).status, "loaded"); + assert.equal(CountingStore.validations, 3, "a bad store cannot invalidate an unrelated valid proof"); +}); + +test("File proof working set evicts least-recently used stores rather than growing with Goal count", async t => { + class CountingStore extends FileAuthorityStore { + static validations = 0; + protected override decodeStoredDocument(value: unknown, identity: string) { + CountingStore.validations++; + return super.decodeStoredDocument(value, identity); + } + } + const roots: string[] = []; + for (let index = 0; index < 5; index++) { + const {root, store} = await fixture(t); + roots.push(root); + assert.equal((await store.commitAuthority(commit(null, "working-set", 1, 1))).status, "applied"); + await writeFile(store.path, (await readFile(store.path, "utf8")) + "\n"); + assert.equal((await new CountingStore(root, "goal-a").loadAuthority()).status, "loaded"); + } + assert.equal(CountingStore.validations, 5); + for (const index of [4, 2, 3, 1]) { + assert.equal((await new CountingStore(roots[index]!, "goal-a").loadAuthority()).status, "loaded"); + } + assert.equal(CountingStore.validations, 5, "four recent stores remain reusable"); + assert.equal((await new CountingStore(roots[0]!, "goal-a").loadAuthority()).status, "loaded"); + assert.equal(CountingStore.validations, 6, "the evicted store must prove history again"); + assert.equal((await new CountingStore(roots[4]!, "goal-a").loadAuthority()).status, "loaded"); + assert.equal(CountingStore.validations, 7, "access order, not insertion identity, determines eviction"); +}); diff --git a/tests/test_cli_argument_diagnostics.py b/tests/test_cli_argument_diagnostics.py index 03d5c9f81..1a476b50d 100644 --- a/tests/test_cli_argument_diagnostics.py +++ b/tests/test_cli_argument_diagnostics.py @@ -136,7 +136,7 @@ def test_quota_include_detail_rejects_unknown_section( assert "--include-detail" in stderr -def test_quota_include_detail_rejects_non_should_run_command( +def test_quota_status_rejects_scheduler_detail( capsys: pytest.CaptureFixture[str], ) -> None: exit_code = main( @@ -155,8 +155,7 @@ def test_quota_include_detail_rejects_non_should_run_command( assert payload["ok"] is False assert payload["error_code"] == "QUOTA_VALIDATION_FAILED" assert payload["error"] == ( - "--include-detail is only valid with `quota should-run` or " - "`quota monitor-poll`" + "`quota status` does not accept --include-detail scheduler" )