diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index bf823d070c..2484f1bb86 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -10,8 +10,8 @@ and size/count budgets. | --- | --- | --- | --- | --- | --- | --- | | `heartbeat_prompt_json` | heartbeat automation | wake and route one bounded turn | `quota should-run`, `status`, or `review-packet --handoff-only` | `json_chars <= 5400` plus `interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 30` | | `review_packet_handoff_only_json` | project-agent handoff | forward the smallest sufficient task packet | full `review-packet` or run-history artifact | `json_chars <= 3000` plus `handoff_interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 18` | -| `quota_should_run_json` | quota guard | decide whether the selected goal may spend compute | `status`, `history`, or active state | `json_chars <= 20500` | `nested_keys <= 525` | `top_level_keys <= 54` | -| `dashboard_status_json` | operator dashboard | render first-screen operator state | `history`, run artifacts, or project-local adapter output | `json_chars <= 24000` | `nested_keys <= 350` | `top_level_keys <= 27` | +| `quota_should_run_json` | quota guard | decide whether the selected goal may spend compute | `status`, `history`, or active state | `json_chars <= 20500` | `nested_keys <= 575` | `top_level_keys <= 55` | +| `dashboard_status_json` | operator dashboard | render first-screen operator state | `history`, run artifacts, or project-local adapter output | `json_chars <= 25000` | `nested_keys <= 350` | `top_level_keys <= 27` | These four budgets measure compact machine payloads. For `heartbeat_prompt_json`, the measured payload is the actual @@ -23,7 +23,7 @@ indentation and Markdown wrappers can make emitted output materially larger; the emitted-output qualification matrix below measures that separate boundary through the real CLI entry point. -The current quota fixture measures 15,419 characters, 378 nested keys, and 52 +The earlier projection-envelope quota fixture measured 15,419 characters, 378 nested keys, and 52 top-level keys on `main`. The `quota_should_run` projection envelope raises the same workload to 18,636 characters, 513 nested keys, and 53 top-level keys. The new keys carry the inherited status sources, direct live-read facts, freshness, @@ -42,6 +42,30 @@ records the useful contract growth instead of hiding it through compaction. 12 个嵌套键与一个顶层键余量。删除 envelope 行会破坏投影合同,因此这里记录有用的 合同增长,而不是用压缩隐藏它。 +Ordinary reads now require canonical authority, so the smoke explicitly imports +its disposable fixture through the saved backup/import plan. On the identical +imported workload, base `0603b49f` and the read-retirement candidate both measure +20,287 quota characters / 564 nested keys / 54 top-level keys and 24,315 status +characters / 338 nested keys / 26 top-level keys. This is fixture qualification, +not candidate payload growth. Retain canonical Task identity, source provenance +and freshness instead of substituting legacy reads or dropping envelope rows. +The original 525-key quota and 24,000-character status failures remain recorded; +their presentation limits become 575 keys and 25,000 characters, leaving 11 +keys and 685 characters. Quota's 54 top-level keys also leave no cadence +headroom under the earlier ceiling; a 55-key limit retains one key of margin. +Quota characters, status key counts, fixture population, +sampling depth and semantic assertions stay unchanged. These limits grant no +execution authority or change to frozen experiment or promotion criteria. + +普通读取现在要求 canonical authority,因此 smoke 通过保存的备份/导入计划显式迁移 +一次性夹具。同一已迁移负载在 base `0603b49f` 与退役候选上完全一致:quota 为 +20,287 字符/564 个嵌套键/54 个顶层键,status 为 24,315 字符/338 个嵌套键/ +26 个顶层键。这是夹具资格补齐,没有候选输出增长。保留 Task 身份、来源与新鲜度, +不以旧读取或删除 envelope 行代替。原 525 键和 24,000 字符失败记录保留;展示限制 +分别改为 575 键与 25,000 字符,余量为 11 键、685 字符。quota 原 54 个顶层键 +已占满旧限、没有 cadence 余量,改为 55 后保留一个键。quota 字符、status 键数、 +夹具数量、采样深度和语义断言不变;不授予执行权限,也不改变冻结的实验或 promotion 标准。 + The successful thin heartbeat projection contains only `schema_version`, `ok`, `goal_id`, optional `agent_id`, `task_body`, and the compact `interface_budget`; exact Turn identity and `bootstrap=true` are included only @@ -407,6 +431,40 @@ qualified replacement replan-context projection and remains a review signal. Coverage-only to dense replan context has a measured, one-time allowance on its three affected JSON surfaces; dense-to-dense changes retain ordinary limits. +### Canonical CLI fixture qualification + +Ordinary CLI reads require canonical authority. Both differential arms explicitly +back up and import the same complete synthetic source before measuring; fixture +setup is outside the measured commands. On base `887d66551` and the read-retirement +candidate, all 96 rows retain their population, format, semantic keys and growth +checks. Every JSON character count is identical. Quota small/multi-agent JSON is +24,099/24,820 characters; their characterization ceilings become 25,000/25,500. +Canonical source witnesses and complete User context remain in the packet. + +The Markdown renderer removes only the second, identical instruction copy, +retaining its wording and placement before the complete JSON source details. +Small/crowded quota becomes 9,558/10,225 characters and 182/166 lines, 475 characters +less than the same canonical baseline. Their limits become 10,000/10,500 characters +and 190/180 lines. Explicit quota detail modes use 10,000 characters/190 lines. +TurnEnvelope measures 10,681 JSON characters, 12,268 with multi-subagent, and +4,521 Markdown characters/135 lines; its stdout limits become 13,000/4,800 +characters and 140 Markdown lines. The original failures remain validation +evidence. Other limits, collection growth, wire-byte targets, execution authority +and frozen experiment criteria do not change. + +普通 CLI 读取要求 canonical authority。差分两臂在测量前都显式备份并导入同一份完整 +合成来源,夹具准备不计入被测命令。base `887d66551` 与退役候选的 96 行保留原数量、 +格式、语义键及增长检查,全部 JSON 字符数相同。quota 小型/多 Agent 为 +24,099/24,820 字符,展示上限调整为 25,000/25,500;保留 canonical 来源见证及 +完整 User 上下文。 + +Markdown 仅去掉第二份完全相同的 instruction,原措辞仍位于完整 JSON 来源详情之前。 +quota 小型/拥挤夹具为 9,558/10,225 字符、182/166 行,比同一 canonical 基线各少 +475 字符;上限调整为 10,000/10,500 字符、190/180 行。显式 quota detail 使用 +10,000 字符/190 行。TurnEnvelope 为 10,681 JSON 字符,启用多子 Agent 后为 +12,268;Markdown 为 4,521 字符/135 行,stdout 上限调整为 13,000/4,800 字符、 +140 行。原失败保留;其它限制、集合增长、wire-byte 目标、执行权限及冻结实验标准不变。 + ### Native fixture qualification The opt-in captured TurnEnvelope heartbeat has a separate 400-character body diff --git a/docs/reference/coordination-observation.md b/docs/reference/coordination-observation.md index 6366e9ca35..8c145598d9 100644 --- a/docs/reference/coordination-observation.md +++ b/docs/reference/coordination-observation.md @@ -13,20 +13,28 @@ status/attention item. No new capability activation or provider selection is required. Existing Goal Channel HTML renders these rows and warnings; this read never sends a message or changes channel bindings. -Before canonical promotion, the source remains the supplied status Todo view -plus local lease files. Python adapts file records; one TS request evaluates -expiry, lease generation and claim conflicts for the whole batch. The legacy -Todo view can be incomplete and does not acquire canonical completeness by using -the shared rule. An explicitly supplied empty `active_leases=[]` does not cause -soft claims to be inferred; local hard-lease observation remains independent. - -After promotion, the selected provider supplies the complete Todo/lease snapshot +Ordinary Todo, status, quota and ownership reads require a completed canonical +Goal cutover. An unmigrated Goal returns `canonical_authority_required` with +backup/migration recovery guidance; it does not read Markdown Todos or local +lease files as a fallback, create an empty authority, or migrate on observation. +Global status keeps that Goal visibly unavailable while independent migrated +Goals remain readable. A corrupt cutover fence is a separate explicit failure. + +The selected provider supplies the complete Todo/lease snapshot in one read. Stale or absent Markdown, legacy lease files and caller-supplied claim/lease display overrides cannot replace canonical ownership facts. An empty canonical result stays empty. Claims of completed/archived Todos do not re-enter the active claim display. Canonical claim/lease conflict comparison happens before output limits or text redaction. +Explicit source inspection, backup, cold import and state conversion retain +their legacy readers. Use Goal settings → Task ownership → Goal data storage +to inspect, back up, preview, confirm and recover a migration. Conversion must +preserve complete Task identities, bodies, archives, relationships, receipts +and leases before ordinary reads become available. File, SQLite and PostgreSQL +remain supported canonical providers; historical canonical record formats are +unaffected by retiring the unmigrated operational path. + `coordination_observation` records the provider source/revision, observation time, record counts, total observations, display limit and truncation. These fields apply only to the ownership observation; other Goal Channel panels remain their @@ -68,14 +76,20 @@ Goal Channel 的 `active_leases` 展示认领与时间上有效的 lease 记录 实际修改仍受相应操作的资格、mode 和 lease 门禁约束。上面的 status 命令可读取 现有 Goal Channel/attention 投影,不新增 activation,也不发送消息。 -晋升前沿用传入的状态 Todo 视图与本地 lease 文件,由同一 TS 批量规则计算时间、 -代数和认领冲突;旧 Todo 视图仍可能不完整。明确传入空列表不再补出 soft claim, -本地 hard lease 仍独立观察。 +普通 Todo、status、quota 和所有权读取要求 Goal 已完成 canonical 切换。未迁移时 +明确返回 `canonical_authority_required` 与备份/迁移指引,不回退 Markdown Todo +或本地 lease,不创建空权威,也不在观察时自动迁移。全局状态保留该 Goal 的不可用 +说明,其他已迁移 Goal 仍可读取;损坏的切换 fence 单独报错。 -晋升后从选定 provider 的完整同一 revision 读取所有权事实。陈旧/缺失 Markdown、 +从选定 provider 的完整同一 revision 读取所有权事实。陈旧/缺失 Markdown、 旧 lease 文件或调用方的展示覆盖值不再成为事实来源;canonical 空值保持为空, 完成/归档 Todo 的认领不进入活动认领展示。先判断完整数据中的冲突,再脱敏与截断。 +显式源检查、备份、冷导入与 state 转换保留旧数据读取能力。通过 Goal 设置 → 任务 +归属 → Goal 数据存储完成检查、备份、预览、确认与恢复。转换须保留完整 Task 身份、 +正文、归档、关系、回执与 lease,再启用普通读取。File、SQLite、PostgreSQL 仍是 +受支持的 canonical provider,历史 canonical 记录格式也继续可读。 + `coordination_observation` 披露来源、版本、观察时间、总数和截断情况,只描述 所有权这一组数据,不声称整张 Goal Channel 的所有面板来自同一 revision。 最多展示 100 条,异常/冲突优先;截断提示意味着不能把可见列表当作完整工作清单。 diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index 6970b4181a..a296adde8f 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -12,6 +12,8 @@ from types import ModuleType from typing import Any, Callable +from loopx.control_plane.testing.canary_harness import import_fixture_authority + PROBE_SCHEMA_VERSION = "loopx_cli_output_probe_v0" FIXTURE_CONTRACT_VERSION = "loopx_cli_output_public_fixture_v0" @@ -193,6 +195,7 @@ def _default_rows( root, scenario, ) + import_fixture_authority(registry_path, probe.GOAL_ID) for output_format in ("json", "markdown"): commands = probe._surface_commands( project=project, @@ -269,6 +272,7 @@ def _variant_rows( fixture_root / "mode_variants", probe.SCENARIOS[0], ) + import_fixture_authority(registry_path, probe.GOAL_ID) rows: list[dict] = [] for output_format in ("json", "markdown"): commands = probe._mode_variant_commands( @@ -343,6 +347,7 @@ def _blocking_gate_rows( f"blocks_agent={probe.AGENT_IDS[0]} " "unblocks_todo_id=todo_fixture_000 -->\n" ) + import_fixture_authority(registry_path, probe.GOAL_ID) command = probe._mode_variant_commands( project=project, runtime=runtime, @@ -404,6 +409,7 @@ def _multi_subagent_rows(probe, semantics, fixture_root, *, enforce_budget=True) "model_config": {"model": "example-small", "reasoning_effort": "max"}, } registry_path.write_text(json.dumps(registry)) + import_fixture_authority(registry_path, probe.GOAL_ID) variant_id = "quota_should_run_turn_envelope" command = probe._mode_variant_commands( project=project, runtime=runtime, registry_path=registry_path, diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 60de0e53c4..94043b3285 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -37,6 +37,7 @@ from loopx.control_plane.scheduler.execution_context import ( # noqa: E402 scheduler_execution_context_for_runtime_profile, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 GOAL_ID = "interface-budget-goal" @@ -79,8 +80,12 @@ # keys. Keep the complete typed envelope rather than dropping source # facts that the consumer must inspect before it spends compute. "max_json_chars": 20_500, - "max_nested_keys": 525, - "max_top_level_keys": 54, + # Explicitly importing this same fixture exposes the current canonical + # read shape on both base/head: 20,287 chars, 564 nested / 54 top-level + # keys. Preserve source/Task provenance; retain 11 nested and one + # top-level key of margin for the cadence check. + "max_nested_keys": 575, + "max_top_level_keys": 55, }, "dashboard_status_json": { "owner": "operator dashboard", @@ -93,7 +98,9 @@ # The original longer temp root exposes 23,274 chars with unchanged # 336 nested / 26 top-level keys. Preserve source revisions and full # paths; 24k leaves 726 chars without widening structural guards. - "max_json_chars": 24_000, + # The same canonical fixture is 24,315 chars on both base/head; the + # 25k ceiling retains 685 chars without dropping authority metadata. + "max_json_chars": 25_000, "max_nested_keys": 350, "max_top_level_keys": 27, }, @@ -150,6 +157,7 @@ def write_registry(root: Path) -> tuple[Path, Path]: + "\n", encoding="utf-8", ) + import_fixture_authority(registry_path, GOAL_ID) return registry_path, project diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index b6b63402bc..5d24184dfe 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -83,7 +83,7 @@ def command(self) -> list[str]: 'if (false) facts.push("base_source_basis_digest_mismatch");')),), 'tests/control_plane_ts/goal_amendment_proposal.test.ts', 'canonical Todo bases cannot'), Case('governance_reads_legacy_after_promotion', (('loopx/control_plane/goals/shared_goal_work_source.py', replacement( - 'if canonical is None:', 'if True:')),), 'tests/control_plane/test_canonical_goal_governance.py::test_empty_canonical_is_authoritative_and_missing_display_is_not_repaired'), + ' state_text = ""', ' state_text = state_path.read_text(encoding="utf-8")')),), 'tests/control_plane/test_canonical_goal_governance.py::test_empty_canonical_is_authoritative_and_missing_display_is_not_repaired'), Case('delivery_wait_target_unbound', (('loopx/control_plane/todos/resume_condition.ts', replacement( 'condition.target_todo_id !== spec.target || ', '')),), 'tests/control_plane_ts/delivery_response.test.ts', 'exact dependency identity'), diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index 7e3b89da1d..d95842b180 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -6,6 +6,7 @@ from typing import Any from ..contract import check_contract, render_contract_markdown +from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable from ..control_plane.runtime.status_projection_cache import ( load_status_projection_cache, resolve_status_projection_cache_runtime_root, @@ -284,6 +285,9 @@ def handle_status_command( ], }, } + if isinstance(exc, LocalCoordinationAuthorityUnavailable): + payload["error_code"] = exc.code + payload["authority_read"] = exc.payload print_payload(payload, output_format(args), render_status_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index c1508283ce..336ba1abef 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -54,7 +54,6 @@ update_goal_todo, ) from ..control_plane.todos.list_readback import list_goal_todos -from ..control_plane.todos.path_resolution import resolve_todo_state from .todo_argument_validation import ( validate_capability_gap_options, validate_shared_todo_options, @@ -197,12 +196,6 @@ def _validated_replan_successor_obligation( runtime_root=runtime_root, goal_id=args.goal_id, ) state_text = "" - if todo_fields is None: - _, _, state_text, _ = resolve_todo_state( - registry_path=registry_path, - goal_id=args.goal_id, - **_todo_path_args(args), - ) existing_runs, _ = load_index( runtime_root / "goals" / args.goal_id / "runs" / "index.jsonl" ) diff --git a/loopx/control_plane/agents/execution_facts.py b/loopx/control_plane/agents/execution_facts.py index 5b326288e7..8ba21662ed 100644 --- a/loopx/control_plane/agents/execution_facts.py +++ b/loopx/control_plane/agents/execution_facts.py @@ -43,8 +43,8 @@ turn_lane_liveness, turn_lane_target, ) -from ..work_items.local_lease_record import TaskLeaseError, read_lease -from ..work_items.task_lease import lease_expires_at, normalize_goal_id, task_lease_path +from ..work_items.task_lease import lease_expires_at, normalize_goal_id +from ..work_items.local_lease_record import TaskLeaseError from .management_projection import LEASE_STATUS_UNAVAILABLE, projected_agent_goals # One agent can hold one lane per Goal; the row reports the strongest fact. @@ -170,7 +170,7 @@ def _open_todo_leases( ) -> tuple[list[Mapping[str, Any]], set[str]] | None: """Readable leases and unreadable Todo IDs; ``None`` if authority is unreadable. - After cutover the canonical head is the only lease source; its failure is + The canonical head is the only lease source; its failure is reported, never replaced by the local files it superseded. The failure classes are the ones the ownership observation already isolates. """ @@ -181,24 +181,8 @@ def _open_todo_leases( ) except (OSError, RuntimeError, ValueError): return None - if canonical is not None: - leases = [lease for lease in canonical.get("leases") or [] if isinstance(lease, Mapping)] - unreadable_todo_ids: set[str] = set() - else: - leases = [] - unreadable_todo_ids = set() - for todo_id in sorted(todo_ids): - try: - lease = read_lease( - task_lease_path(runtime_root=runtime_root, goal_id=goal_id, todo_id=todo_id) - ) - except (TaskLeaseError, OSError): - # Keep the affected claimant uncertain without hiding healthy peers. - unreadable_todo_ids.add(todo_id) - continue - if lease: - leases.append(lease) - return [lease for lease in leases if lease.get("todo_id") in todo_ids], unreadable_todo_ids + return [lease for lease in canonical["leases"] + if isinstance(lease, Mapping) and lease.get("todo_id") in todo_ids], set() def _lease_fact(lease: Mapping[str, Any], *, at: datetime) -> dict[str, Any]: diff --git a/loopx/control_plane/coordination/canonical_snapshot.py b/loopx/control_plane/coordination/canonical_snapshot.py index ea0aaae976..e079268577 100644 --- a/loopx/control_plane/coordination/canonical_snapshot.py +++ b/loopx/control_plane/coordination/canonical_snapshot.py @@ -75,6 +75,10 @@ def require(condition: bool, message: str) -> None: "source_authority", "decision_read_from_provider", "legacy_fallback_used", + "goal_id", + "operation", + "write_admitted", + "recovery", ) if key in page }, diff --git a/loopx/control_plane/coordination/canonical_snapshot_page.ts b/loopx/control_plane/coordination/canonical_snapshot_page.ts index b417db403a..38790fee27 100644 --- a/loopx/control_plane/coordination/canonical_snapshot_page.ts +++ b/loopx/control_plane/coordination/canonical_snapshot_page.ts @@ -8,6 +8,7 @@ import {decodeProjectionReadback, confirmProjectionReadback} from "../todos/proj import {authorityStoreSourceAuthority, type AuthorityStore} from "./authority_store.ts"; import {canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; import {canonicalTodoCollection} from "./local_authority_read.ts"; +import {canonicalAuthorityRouteFailure, requireCanonicalAuthority} from "./local_authority_write.ts"; import {openRuntimeAuthorityStore, requireLocalAuthorityRuntimeRoot, localAuthorityOpenFailure, type LocalAuthorityProviderDependencies} from "./local_authority_provider.ts"; @@ -161,13 +162,15 @@ export async function readCanonicalSnapshotPage(value: unknown, const input = requireJsonObject(value, "canonical snapshot request"); const root = requireLocalAuthorityRuntimeRoot(input.runtime_root); const goal = requireAuthorityStoreId(input.goal_id, "goal id"); + await requireCanonicalAuthority(root, goal, "todo_snapshot"); const store = await openRuntimeAuthorityStore(root, goal, dependencies, {existingOnly: true}); return await readCanonicalSnapshotFromStore(input, store, completeBytes); } catch (error) { return {schema_version: CANONICAL_SNAPSHOT_PAGE_RESULT, status: "failed", reason_code: error instanceof SnapshotReadError ? error.code : "canonical_snapshot_request_invalid", reason: error instanceof Error ? error.message : "canonical snapshot read failed", - decision_read_from_provider: true, legacy_fallback_used: false, ...localAuthorityOpenFailure(error)}; + decision_read_from_provider: true, legacy_fallback_used: false, + ...localAuthorityOpenFailure(error), ...canonicalAuthorityRouteFailure(error)}; } } diff --git a/loopx/control_plane/coordination/handoff_mode_runtime.ts b/loopx/control_plane/coordination/handoff_mode_runtime.ts index e63f19cf46..e697e000f7 100644 --- a/loopx/control_plane/coordination/handoff_mode_runtime.ts +++ b/loopx/control_plane/coordination/handoff_mode_runtime.ts @@ -5,7 +5,7 @@ import {requireAuthorityStoreId} from "./authority_store_codec.ts"; import {openLocalAuthorityStore, localAuthorityOpenFailure} from "./local_authority_provider.ts"; import {requireLocalAuthorityRuntimeRoot as runtimeRoot} from "./local_authority_provider.ts"; import {authorityStoreSourceAuthority as sourceAuthorityFor} from "./authority_store.ts"; -import {canonicalWriteRouteFailure, withCanonicalWriter} from "./local_authority_write.ts"; +import {canonicalAuthorityRouteFailure, withCanonicalWriter} from "./local_authority_write.ts"; import {ShadowManagementError} from "./shadow_management.ts"; import {executeHandoffModeSet, HANDOFF_MODE_SET_SCHEMA} from "./handoff_mode_transaction.ts"; import {planHandoffModeMigration, executeHandoffModeMigration} from "./handoff_mode_migration.ts"; @@ -29,7 +29,7 @@ export async function setLocalHandoffMode(value: unknown): Promise { return {schema_version: "loopx_coordination_handoff_mode_set_result_v0", status: "failed", changed: false, reason_code: error instanceof ShadowManagementError ? error.reason_code : "handoff_mode_unavailable", reason: error instanceof Error ? error.message : String(error), ...evidence, - ...localAuthorityOpenFailure(error), ...canonicalWriteRouteFailure(error)}; + ...localAuthorityOpenFailure(error), ...canonicalAuthorityRouteFailure(error)}; } } diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index 9ba3f10cb4..b3a7647fcd 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -1,8 +1,8 @@ """Python adapter for provider-first local coordination reads. TypeScript remains the semantic owner of canonical projection validation. The -Python CLI only detects whether cutover is engaged, invokes that owner, and -fails closed instead of consulting the legacy Markdown projection. +Python CLI invokes that owner for admission and snapshot validation, and fails +closed instead of consulting the legacy Markdown projection. """ from __future__ import annotations @@ -42,7 +42,7 @@ class LocalCoordinationAuthorityUnavailable(RuntimeError): - """Canonical coordination state cannot safely answer a post-cutover read.""" + """Canonical coordination state cannot safely answer an ordinary read.""" def __init__(self, message: str, *, code: str, payload: Mapping[str, Any]) -> None: super().__init__(message) @@ -70,7 +70,7 @@ def __init__(self, message: str, *, code: str, payload: Mapping[str, Any]) -> No class CanonicalAuthorityRequired(LocalCoordinationAuthorityRejection): - """A normal write targeted a Goal that has not completed authority migration.""" + """An ordinary operation targeted a Goal before authority migration.""" def __init__( self, @@ -247,17 +247,13 @@ def claim_canonical_todo_if_promoted( def read_canonical_todos_if_promoted( *, runtime_root: Path, goal_id: str, include_leases: bool = False, projection_readback: Mapping[str, Any] | None = None, -) -> dict[str, Any] | None: - """Return canonical Todos after cutover, or ``None`` before cutover. +) -> dict[str, Any]: + """Read canonical Todos or fail with the native migration/recovery cause. - Presence of the durable writer fence is the mode switch. Once present, - every malformed, missing, or unavailable provider response is terminal for - this read; callers must never recover by reading Markdown. + The historical import name no longer permits pre-cutover Markdown reads. + Explicit migration captures use the dedicated source inspection owner. """ - if not local_authority_is_promoted(runtime_root=runtime_root, goal_id=goal_id): - return None - result = read_canonical_snapshot( rpc=effect_runtime_result, runtime_root=str(runtime_root.expanduser().resolve(strict=False)), @@ -272,6 +268,10 @@ def read_canonical_todos_if_promoted( payload={"source_authority": "file_v0"}, ) payload = dict(result) + if payload.get("reason_code") == "canonical_authority_required": + raise CanonicalAuthorityRequired( + goal_id=goal_id, operation="todo_read", payload=payload, + ) todos = payload.get("todos") todo_read_model = payload.get("todo_read_model") if ( @@ -333,10 +333,10 @@ class CanonicalTodoSnapshot: def __init__(self) -> None: self._reads: dict[ - tuple[Path, str], dict[str, Any] | None | LocalCoordinationAuthorityUnavailable + tuple[Path, str], dict[str, Any] | LocalCoordinationAuthorityUnavailable ] = {} - def read(self, *, runtime_root: Path, goal_id: str) -> dict[str, Any] | None: + def read(self, *, runtime_root: Path, goal_id: str) -> dict[str, Any]: key = (runtime_root.expanduser().resolve(strict=False), goal_id) if key not in self._reads: try: @@ -362,8 +362,8 @@ def read(self, *, runtime_root: Path, goal_id: str) -> dict[str, Any] | None: def read_canonical_todo_fields_if_promoted( *, runtime_root: Path, goal_id: str, rollout_events: list[dict[str, Any]] | None = None, -) -> dict[str, Any] | None: - """Read one unbounded planning snapshot; None alone permits legacy parsing. +) -> dict[str, Any]: + """Read one complete canonical planning snapshot, including empty state. Empty canonical state is authoritative. Provider failures propagate; this read neither repairs Markdown nor grants mutation/promotion authority. @@ -374,7 +374,6 @@ def read_canonical_todo_fields_if_promoted( canonical_todo_summary_fields(canonical["todos"], rollout_events=rollout_events, goal_acceptance_contract=canonical.get("goal_acceptance_contract"), goal_acceptance_work_guards=canonical.get("goal_acceptance_work_guards")) - if canonical is not None else None ) diff --git a/loopx/control_plane/coordination/local_authority_read.ts b/loopx/control_plane/coordination/local_authority_read.ts index 238ee132c5..f76cddd6ec 100644 --- a/loopx/control_plane/coordination/local_authority_read.ts +++ b/loopx/control_plane/coordination/local_authority_read.ts @@ -12,6 +12,7 @@ import {CoordinationProjectionRead, indexCoordinationProjectionTodos, validateCo import {LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_READ_RESULT_SCHEMA} from "./coordination_state_contract.generated.ts"; import {decodeProjectionReadback, confirmProjectionReadback} from "../todos/projection_delivery.ts"; +import {canonicalAuthorityRouteFailure, requireCanonicalAuthority} from "./local_authority_write.ts"; import {completionTurnOperationId, COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA} from "./todo_terminal_lifecycle.ts"; @@ -242,6 +243,7 @@ export async function readLocalCoordinationTodo( const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); const todoId = requireAuthorityStoreId(input.todo_id, "todo id"); + await requireCanonicalAuthority(root, goalId, "todo_read"); const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); const head = await store.loadAuthority(); @@ -281,6 +283,7 @@ export async function readLocalCoordinationTodo( decision_read_from_provider: true, legacy_fallback_used: false, ...localAuthorityOpenFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } @@ -302,6 +305,7 @@ export async function listLocalCoordinationTodos( } const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); + await requireCanonicalAuthority(root, goalId, "todo_list"); const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); const head = await store.loadAuthority(); @@ -347,6 +351,7 @@ export async function listLocalCoordinationTodos( decision_read_from_provider: true, legacy_fallback_used: false, ...localAuthorityOpenFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 72f4b1d7d0..37d07db622 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -8,7 +8,7 @@ import {decodeTaskLeaseProof} from "./task_lease_proof.ts"; import {COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA} from "./todo_archive.ts"; import {readCoordinationOwnership} from "./ownership_observation.ts"; import {executeTodoContinuation} from "./todo_continuation.ts"; -import {canonicalWriteRouteFailure, withCanonicalWriter} from "./local_authority_write.ts"; +import {canonicalAuthorityRouteFailure, requireCanonicalAuthority, withCanonicalWriter} from "./local_authority_write.ts"; import { ShadowManagementError, withShadowMaintenanceLock } from "./shadow_management.ts"; import { isAbsolute, join } from "node:path"; import {readFile, realpath} from "node:fs/promises"; @@ -489,7 +489,7 @@ export async function pollLocalCoordinationMonitor(value: unknown, return {schema_version: COORDINATION_MONITOR_POLL_RESULT_SCHEMA, status: "failed", changed: false, reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_monitor_poll_request", reason: error instanceof Error ? error.message : String(error), ...evidence, - ...localAuthorityOpenFailure(error), ...canonicalWriteRouteFailure(error)}; + ...localAuthorityOpenFailure(error), ...canonicalAuthorityRouteFailure(error)}; } } @@ -1096,7 +1096,7 @@ export async function claimLocalCoordinationTodo( decision_read_from_provider: true, legacy_fallback_used: false, ...localAuthorityOpenFailure(error), - ...canonicalWriteRouteFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } @@ -1166,7 +1166,7 @@ export async function createLocalCoordinationTodo( reason: error instanceof Error ? error.message : "invalid Todo create request", ...providerEvidence, ...localAuthorityOpenFailure(error), - ...canonicalWriteRouteFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } @@ -1272,7 +1272,7 @@ export async function updateLocalCoordinationTodo( reason: error instanceof Error ? error.message : "invalid Todo update request", ...providerEvidence, ...localAuthorityOpenFailure(error), - ...canonicalWriteRouteFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } @@ -1395,7 +1395,7 @@ export async function terminalLifecycleLocalCoordinationTodo( reason: error instanceof Error ? error.message : "invalid local Todo terminal request", ...providerEvidence, ...localAuthorityOpenFailure(error), - ...canonicalWriteRouteFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } @@ -1447,7 +1447,7 @@ export async function archiveLocalCoordinationTodos( reason: error instanceof Error ? error.message : "invalid local Todo archive request", ...providerEvidence, ...localAuthorityOpenFailure(error), - ...canonicalWriteRouteFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } @@ -1478,7 +1478,7 @@ export async function acknowledgeLocalCoordinationTodoArchive( "invalid_local_coordination_todo_archive_ack_request", reason: error instanceof Error ? error.message : "invalid archive acknowledgement", ...localAuthorityOpenFailure(error), - ...canonicalWriteRouteFailure(error), + ...canonicalAuthorityRouteFailure(error), }; } } @@ -1502,7 +1502,7 @@ export async function continueLocalTodo( return {ok: false, status: "failed", reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_continuation_request", reason: error instanceof Error ? error.message : "Invalid continuation request", ...evidence, - ...localAuthorityOpenFailure(error), ...canonicalWriteRouteFailure(error)}; + ...localAuthorityOpenFailure(error), ...canonicalAuthorityRouteFailure(error)}; } } @@ -1514,6 +1514,7 @@ export async function observeLocalCoordinationOwnership(value: unknown): Promise if (input.schema_version !== "loopx_local_ownership_observation_request_v0") throw new Error("ownership observation schema mismatch"); const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); + await requireCanonicalAuthority(root, goalId, "ownership_observation"); const store = await openLocalAuthorityStore(root, goalId); sourceAuthority = sourceAuthorityFor(store); return {...await readCoordinationOwnership(store, goalId, input.observed_at as string), @@ -1521,7 +1522,8 @@ export async function observeLocalCoordinationOwnership(value: unknown): Promise } catch (error) { return {schema_version: "loopx_ownership_observation_result_v0", status: "failed", reason_code: "coordination_observation_unavailable", source_authority: sourceAuthority, - decision_read_from_provider: true, legacy_fallback_used: false, ...localAuthorityOpenFailure(error)}; + decision_read_from_provider: true, legacy_fallback_used: false, + ...localAuthorityOpenFailure(error), ...canonicalAuthorityRouteFailure(error)}; } } diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index 81333cc984..4880aed477 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -23,13 +23,13 @@ export type CanonicalBusinessWrite = | "task_lease_release" | "goal_create"; -export class CanonicalWriteRouteError extends Error { +export class CanonicalAuthorityRouteError extends Error { readonly code: "canonical_authority_required" | "canonical_authority_fence_invalid"; readonly payload: JsonObject; - constructor(code: CanonicalWriteRouteError["code"], goalId: string, operation: CanonicalBusinessWrite, reason: string) { + constructor(code: CanonicalAuthorityRouteError["code"], goalId: string, operation: string, reason: string) { super(reason); - this.name = "CanonicalWriteRouteError"; + this.name = "CanonicalAuthorityRouteError"; this.code = code; this.payload = { status: "failed", @@ -49,23 +49,24 @@ export class CanonicalWriteRouteError extends Error { } } -export function canonicalWriteRouteFailure(error: unknown): JsonObject { - return error instanceof CanonicalWriteRouteError ? {...error.payload, reason: error.message} : {}; +export function canonicalAuthorityRouteFailure(error: unknown): JsonObject { + return error instanceof CanonicalAuthorityRouteError ? {...error.payload, reason: error.message} : {}; } interface CanonicalWriterOptions { businessWrite?: CanonicalBusinessWrite; } -/** Call only while holding the Goal maintenance lock. */ -export async function requireCanonicalBusinessWrite( +/** Ordinary reads and writes require the same completed Goal cutover. + * Explicit source inspection/import keeps its own migration admission. */ +export async function requireCanonicalAuthority( root: string, goalId: string, - operation: CanonicalBusinessWrite, + operation: string, ): Promise { const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "missing") { - throw new CanonicalWriteRouteError( + throw new CanonicalAuthorityRouteError( "canonical_authority_required", goalId, operation, @@ -73,7 +74,7 @@ export async function requireCanonicalBusinessWrite( ); } if (fence.status === "failed") { - throw new CanonicalWriteRouteError( + throw new CanonicalAuthorityRouteError( "canonical_authority_fence_invalid", goalId, operation, @@ -93,7 +94,7 @@ export async function withCanonicalWriter( return await withFileMutationLock(shadowMaintenanceLockPath(root, goalId), async () => { await requireShadowPrimaryWriteAllowed(root, goalId); if (options.businessWrite !== undefined) { - await requireCanonicalBusinessWrite(root, goalId, options.businessWrite); + await requireCanonicalAuthority(root, goalId, options.businessWrite); } return await write(); }, CANONICAL_WRITER_LOCK_TIMEOUT_MS); diff --git a/loopx/control_plane/goals/coordination_observation.py b/loopx/control_plane/goals/coordination_observation.py index 737c25edcf..e4e223d5e8 100644 --- a/loopx/control_plane/goals/coordination_observation.py +++ b/loopx/control_plane/goals/coordination_observation.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any -from ..coordination.local_authority import LOCAL_AUTHORITY_SOURCES, local_authority_is_promoted +from ..coordination.local_authority import LOCAL_AUTHORITY_SOURCES from ..effect_runtime import effect_runtime_result from ..runtime.time import now_local_iso @@ -29,48 +29,18 @@ def coordination_authority_transition(observation: dict[str, Any]) -> dict[str, return {"state": state} -def observe_goal_coordination(*, runtime_root: Any, goal_id: str, - agent_todos: list[dict[str, Any]], - explicit_entries: list[dict[str, Any]] | None) -> dict[str, Any]: - canonical = False +def observe_goal_coordination(*, runtime_root: Any, goal_id: str) -> dict[str, Any]: try: - root = Path(runtime_root) if runtime_root is not None else None - if root is not None: - canonical = local_authority_is_promoted(runtime_root=root, goal_id=goal_id) - if canonical: - assert root is not None - result = effect_runtime_result('coordination.local_authority.ownership_observation', { - 'schema_version': 'loopx_local_ownership_observation_request_v0', - 'runtime_root': str(root.expanduser().resolve()), 'goal_id': goal_id, - 'observed_at': now_local_iso(), - }) - if (not isinstance(result, dict) or result.get('source_authority') not in LOCAL_AUTHORITY_SOURCES - or result.get('decision_read_from_provider') is not True or result.get('legacy_fallback_used') is not False - or not isinstance(result.get('provider_revision'), str)): - raise RuntimeError('canonical ownership observation unavailable') - else: - from ..work_items.local_lease_record import TaskLeaseError - from ..work_items import task_lease as task_lease_module - rows = [] - if root is not None: - for path in sorted(task_lease_module.task_lease_dir(runtime_root=root, goal_id=goal_id).glob('todo_*.json')): - try: - # task_lease re-exports this seam and existing callers/tests patch it there. - lease = task_lease_module.read_lease(path) # type: ignore[attr-defined] - except FileNotFoundError: - continue - except (TaskLeaseError, OSError): - rows.append({'todo_id': path.stem, 'unreadable': True}) - else: - if lease is not None: - rows.append({'todo_id': path.stem, 'lease': lease}) - if not rows and not agent_todos and not explicit_entries: - return {'status': 'loaded', 'entries': []} - result = effect_runtime_result('coordination.ownership_observation', { - 'schema_version': 'loopx_ownership_observation_request_v0', - 'todos': agent_todos, 'lease_rows': rows, 'explicit_entries': explicit_entries, - 'observed_at': now_local_iso(), - }) + root = Path(runtime_root) + result = effect_runtime_result('coordination.local_authority.ownership_observation', { + 'schema_version': 'loopx_local_ownership_observation_request_v0', + 'runtime_root': str(root.expanduser().resolve()), 'goal_id': goal_id, + 'observed_at': now_local_iso(), + }) + if (not isinstance(result, dict) or result.get('source_authority') not in LOCAL_AUTHORITY_SOURCES + or result.get('decision_read_from_provider') is not True or result.get('legacy_fallback_used') is not False + or not isinstance(result.get('provider_revision'), str)): + raise RuntimeError('canonical ownership observation unavailable') if (not isinstance(result, dict) or result.get('schema_version') != 'loopx_ownership_observation_result_v0' or result.get('status') != 'loaded' or not isinstance(result.get('entries'), list) or any(not isinstance(row, dict) for row in result['entries'])): @@ -79,4 +49,4 @@ def observe_goal_coordination(*, runtime_root: Any, goal_id: str, except (OSError, RuntimeError, TypeError, ValueError): # Observation failure is visible, but never activates a legacy fallback or leaks provider errors. return {'status': 'unavailable', 'entries': [], 'legacy_fallback_used': False, - 'source_authority': 'canonical_unavailable' if canonical else 'unavailable'} + 'source_authority': 'canonical_unavailable'} diff --git a/loopx/control_plane/goals/goal_channel_projection.py b/loopx/control_plane/goals/goal_channel_projection.py index 75c6383baa..a8b5d4a648 100644 --- a/loopx/control_plane/goals/goal_channel_projection.py +++ b/loopx/control_plane/goals/goal_channel_projection.py @@ -355,14 +355,7 @@ def build_goal_channel_projection( observe_goal_coordination, ) - explicit = None if active_leases is None else [ - _compact_coordination_entry({**{key: item[key] for key in ("todo_id", "status", "lease_until", "write_scope") if key in item}, - "owner_agent": _first_text(item.get("owner_agent"), item.get("claimed_by"), limit=120)}) - for item in _as_mappings(active_leases) - if _text(item.get("todo_id"), limit=120) or _first_text(item.get("owner_agent"), item.get("claimed_by"), limit=120) - ] - observation = observe_goal_coordination(runtime_root=runtime_root, goal_id=str(goal_id), - agent_todos=agent_todos, explicit_entries=explicit) + observation = observe_goal_coordination(runtime_root=runtime_root, goal_id=str(goal_id)) raw_keys = _raw_material_keys( status_item_dict, status_payload_dict, diff --git a/loopx/control_plane/goals/goal_frontier/fallback_source.py b/loopx/control_plane/goals/goal_frontier/fallback_source.py index a98beed950..750e7e8b82 100644 --- a/loopx/control_plane/goals/goal_frontier/fallback_source.py +++ b/loopx/control_plane/goals/goal_frontier/fallback_source.py @@ -267,23 +267,10 @@ def read_fallback_source_snapshot( ) -> list[dict[str, Any]]: # IO dependencies are loaded on demand; the public frontier package also # serves the history/quota read models during their initial import. - from ....history import load_registry - from ....state_refresh import resolve_goal_state from ...coordination.local_authority import read_canonical_todos_if_promoted - from ...todos.active_state_todo_parser import parse_todo_source canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) - if canonical is not None: - # Failure after promotion must not consult the Markdown projection. - return canonical["todos"] - goal, _, state_file = resolve_goal_state( - registry=load_registry(registry_path), goal_id=goal_id, - project_override=None, state_file_override=None, - ) - if goal is None: - raise ValueError("fallback source goal is absent") - groups, archived, _ = parse_todo_source(state_file.read_text(encoding="utf-8")) - return [*groups["user"], *groups["agent"], *archived] + return canonical["todos"] def live_fallback_authority_items( diff --git a/loopx/control_plane/goals/shared_goal_alignment.py b/loopx/control_plane/goals/shared_goal_alignment.py index 113828c0e1..7a883a6e03 100644 --- a/loopx/control_plane/goals/shared_goal_alignment.py +++ b/loopx/control_plane/goals/shared_goal_alignment.py @@ -1,7 +1,7 @@ """Read-only shared Goal alignment over the selected Todo/lease authority. -Promoted Goals carry a provider revision; legacy Markdown has no monotonic -revision. Neither source fabricates an event sequence or an Agent frontier. +Canonical Goals carry a provider revision. The reader never fabricates an +event sequence or an Agent frontier. The historical sequence fields remain zero/unbound for persisted proposal compatibility; typed alignment and amendment admission decide what is provable. """ @@ -29,7 +29,6 @@ SHARED_GOAL_ALIGNMENT_EFFECT_METHOD = "goal.shared_goal_alignment.project" SHARED_GOAL_ALIGNMENT_REQUEST_SCHEMA_VERSION = "shared_goal_alignment_request_v0" SHARED_GOAL_ALIGNMENT_SCHEMA_VERSION = "shared_goal_alignment_v0" -REVISION_BASIS_MARKDOWN_ACTIVE_STATE = "markdown_active_state" BASIS_SOURCE_UNBOUND = "unbound" DEFAULT_REGISTRY_RELATIVE_PATH = Path(".loopx") / "registry.json" @@ -146,16 +145,8 @@ def _project_shared_goal_alignment( str(frontmatter.get("status") or "").strip() or None ) - revision_basis = ("canonical_todo_snapshot" if source.canonical_basis is not None - else REVISION_BASIS_MARKDOWN_ACTIVE_STATE) + revision_basis = "canonical_todo_snapshot" basis_sequence = 0 - # Bind the same typed work/lease facts sent to the reducer. Markdown has - # no monotonic revision, but a peer claim can change without updated_at. - # Compatibility prose, display order and read time are not work identity. - source_checksum = ( - _canonical_digest(sorted(source.items, key=lambda item: str(item.get("todo_id") or ""))) - if source.canonical_basis is None else None - ) source_basis_digest = _canonical_digest( _source_basis_facts_envelope( @@ -164,7 +155,7 @@ def _project_shared_goal_alignment( registered_agents=registered_agents, revision_basis=revision_basis, last_append_sequence=None, - source_checksum=source_checksum, + source_checksum=None, state_updated_at=state_updated_at, todo_basis=source.canonical_basis, ) @@ -174,7 +165,7 @@ def _project_shared_goal_alignment( "source_basis_digest": source_basis_digest, "revision_basis": revision_basis, "state_updated_at": state_updated_at, - **({"todo_basis": source.canonical_basis} if source.canonical_basis is not None else {}), + "todo_basis": source.canonical_basis, } frontier_basis = {"based_on_state_event_sequence": None, diff --git a/loopx/control_plane/goals/shared_goal_work_source.py b/loopx/control_plane/goals/shared_goal_work_source.py index 4f7ae1692f..04c3583385 100644 --- a/loopx/control_plane/goals/shared_goal_work_source.py +++ b/loopx/control_plane/goals/shared_goal_work_source.py @@ -1,7 +1,6 @@ """One read-only Todo/lease source per alignment or amendment decision. -Before promotion, parse the existing display and lease files. After promotion, -read both collections at one provider revision; never consult or repair display. +Read both collections at one provider revision; never consult or repair display. This is an adapter over the canonical summary, not a second Todo inventory. """ from dataclasses import dataclass @@ -11,10 +10,7 @@ from ...registry import resolve_state_file from ..coordination.local_authority import canonical_todo_summary_fields, read_canonical_todos_if_promoted -from ..todos.active_state_todo_parser import parse_active_state_todos from ..todos.contract import normalize_todo_excluded_agents -from ..work_items.local_lease_record import TaskLeaseError, read_lease -from ..work_items.task_lease import task_lease_path @dataclass(frozen=True) @@ -23,7 +19,7 @@ class SharedGoalWorkSource: state_path: Path state_text: str items: list[dict[str, Any]] - canonical_basis: dict[str, Any] | None + canonical_basis: dict[str, Any] observed_at: str @@ -33,24 +29,16 @@ def read_shared_goal_work_source(*, goal: dict[str, Any], project: Path, state_path = resolve_state_file(project, goal.get("state_file")) if state_path is None: raise ValueError(f"goal state file is missing for {goal_id}") + if runtime_root is None: + raise ValueError("canonical work reads require a runtime root") canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id, - include_leases=True) if runtime_root is not None else None - if canonical is None: - from .legacy_event_source import require_no_legacy_todo_events - require_no_legacy_todo_events(goal, state_path=state_path) - if not state_path.is_file(): - raise ValueError(f"goal state file is missing for {goal_id}") - state_text = state_path.read_text(encoding="utf-8") - fields = parse_active_state_todos(state_text, goal=goal, state_path=state_path, item_limit=None) - basis = None - leases = None - else: - state_text = "" - fields = canonical_todo_summary_fields(canonical["todos"]) - basis = {"source_authority": canonical["source_authority"], - "provider_revision": canonical["provider_revision"], - "records_sha256": canonical["todo_read_model"]["records_sha256"]} - leases = {lease["todo_id"]: lease for lease in canonical["leases"]} + include_leases=True) + state_text = "" + fields = canonical_todo_summary_fields(canonical["todos"]) + basis = {"source_authority": canonical["source_authority"], + "provider_revision": canonical["provider_revision"], + "records_sha256": canonical["todo_read_model"]["records_sha256"]} + leases = {lease["todo_id"]: lease for lease in canonical["leases"]} summary = fields.get("agent_todos") or {} items = [] for source in summary.get("items") or []: @@ -60,14 +48,7 @@ def read_shared_goal_work_source(*, goal: dict[str, Any], project: Path, item["lease"] = None todo_id = item["todo_id"] if todo_id and source.get("claimed_by"): - try: - item["lease"] = leases.get(todo_id) if leases is not None else ( - read_lease(task_lease_path(runtime_root=runtime_root, goal_id=goal_id, todo_id=todo_id)) - if runtime_root is not None else None) - except TaskLeaseError: - # Let the typed selector reject a corrupt *selected* claim. - # Unrelated historical/peer lease files must not block this Agent. - item["lease_read_error"] = "corrupt_lease" + item["lease"] = leases.get(todo_id) items.append(item) return SharedGoalWorkSource(goal_id, state_path, state_text, items, basis, datetime.now(timezone.utc).isoformat()) diff --git a/loopx/control_plane/goals/start_goal_todo_delta.py b/loopx/control_plane/goals/start_goal_todo_delta.py index 0deb72fbdb..84ac4553a6 100644 --- a/loopx/control_plane/goals/start_goal_todo_delta.py +++ b/loopx/control_plane/goals/start_goal_todo_delta.py @@ -17,13 +17,12 @@ from ..coordination.local_authority import read_canonical_todo_fields_if_promoted from ...history import load_registry from ...paths import resolve_runtime_root -from ...control_plane.todos.active_state_todo_parser import parse_active_state_todos from ...control_plane.todos.contract import ( TODO_TASK_CLASS_ADVANCEMENT, ) from ...control_plane.todos.todo_semantics import todo_item_is_actionable_open from ...project_prompt import render_cli_command_prefix, shell_arg -from ...registry import registry_goals, resolve_state_file +from ...registry import registry_goals GUIDED_TODO_DELTA_SCHEMA_VERSION = "loopx_guided_todo_delta_v0" @@ -44,10 +43,10 @@ def existing_runnable_agent_frontier( whose resume condition is satisfied (or absent) enter the frontier. Blocked, deferred, monitor, blocker, resume-blocked, or peer-claimed Todos never enter the frontier. Returns ``None`` whenever the frontier - cannot be proven (not connected, unknown goal, missing legacy state, or - nothing runnable), so callers keep the unconditional planning contract. - After cutover only canonical records count; provider unavailability raises - instead of being mistaken for an empty frontier. A missing display is safe + is not connected, the Goal is unknown, or nothing is runnable, so callers + keep the unconditional planning contract. Only canonical records count; + an unmigrated Goal or unavailable provider raises instead of being mistaken + for an empty frontier. A missing display is safe to ignore, not permission to reconstruct its non-Todo narrative. """ if inspection.get("connection_state") != "connected": @@ -64,26 +63,12 @@ def existing_runnable_agent_frontier( ) if registry_goal is None: return None - state_file = resolve_state_file( - Path(str(inspection.get("project") or "")), - registry_goal.get("state_file"), - ) parsed = read_canonical_todo_fields_if_promoted( runtime_root=resolve_runtime_root( registry_payload or {}, runtime_root_arg, registry_path=registry_path, ), goal_id=resolved_goal_id, ) - if parsed is None: - if state_file is None or not state_file.is_file(): - return None - try: - state_text = state_file.read_text(encoding="utf-8") - except OSError: - return None - parsed = parse_active_state_todos( - state_text, goal=registry_goal, state_path=state_file, item_limit=None, - ) agent_summary = parsed.get("agent_todos") if isinstance(parsed, dict) else None items = ( agent_summary.get("items", []) if isinstance(agent_summary, dict) else [] diff --git a/loopx/control_plane/projects/registry.py b/loopx/control_plane/projects/registry.py index 642ed8a4cf..c52a8c05b2 100644 --- a/loopx/control_plane/projects/registry.py +++ b/loopx/control_plane/projects/registry.py @@ -16,6 +16,7 @@ ) from ..runtime.document_io import atomic_write_state_text as _atomic_write_text from ..coordination.local_authority import ( + CanonicalAuthorityRequired, read_canonical_todos_if_promoted, require_canonical_authority, ) @@ -176,20 +177,21 @@ def _bind_project_goal_storage( and isinstance(existing_goal.get("creation_operation_id"), str) and existing_goal["creation_operation_id"] != "" ) - existing_authority = ( - read_canonical_todos_if_promoted( - runtime_root=effective_root, - goal_id=goal_id, - ) - if existing_goal is None or not resumable_creation - else None - ) + existing_authority = None + if not resumable_creation: + try: + existing_authority = read_canonical_todos_if_promoted( + runtime_root=effective_root, goal_id=goal_id, + ) + except CanonicalAuthorityRequired: + # A new registration enters the explicit creation transaction. + # An existing unmigrated Goal must use its migration owner. + if existing_goal is not None: + raise if existing_goal is None and existing_authority is not None: raise ValueError( f"goal_id is already bound to canonical authority through another registry: {goal_id}" ) - if existing_goal is not None and existing_authority is None and not resumable_creation: - require_canonical_authority(goal_id=goal_id, operation="goal_create") storage_target = ( existing_target if existing_goal is not None diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index c372d0010c..06403e834b 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -143,20 +143,21 @@ class CliOutputCommandClassification: semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", max_chars={ - # Fixed-path base/head emit 21,871 JSON / 8,690 Markdown chars. - # Keep the selected-Todo source, freshness, ordering and stop rules. - "small": {"json": 22_000, "markdown": 9_000}, + # The same explicitly imported fixture on base/head emits 24,099 + # JSON chars. Keep canonical provenance and complete User context. + "small": {"json": 25_000, "markdown": 10_000}, # Same 36-Todo / 12-run source: 38,432 -> 39,101 chars with # objective/evidence guidance; small and multi-agent paths unchanged. # Per-Todo and fixed semantic growth are accounted for separately. - "crowded": {"json": 40_000, "markdown": 7_800}, - # Default Goal + flat work view: matched fixture 20,805 -> 23,745. - # Keep the bounded alternatives and their omission/detail facts. - "multi_agent": {"json": 24_000, "markdown": 7_000}, + # Canonical source witnesses use the full Markdown context carrier; + # the unchanged 36-Todo fixture emits 10,225 chars / 166 lines. + "crowded": {"json": 40_000, "markdown": 10_500}, + # Matched canonical base/head both emit 24,820 JSON characters. + "multi_agent": {"json": 25_500, "markdown": 7_000}, }, max_lines={ - "small": {"json": 600, "markdown": 140}, - "crowded": {"json": 950, "markdown": 90}, + "small": {"json": 600, "markdown": 190}, + "crowded": {"json": 950, "markdown": 180}, "multi_agent": {"json": 650, "markdown": 80}, }, scale_axis="todo_count", @@ -412,8 +413,8 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", - max_chars={"json": 31_000, "markdown": 9_000}, - max_lines={"json": 820, "markdown": 140}, + max_chars={"json": 31_000, "markdown": 10_000}, + max_lines={"json": 820, "markdown": 190}, ), CliOutputModeVariantSpec( variant_id="quota_should_run_todo_summary_detail", @@ -422,8 +423,8 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", - max_chars={"json": 55_000, "markdown": 9_000}, - max_lines={"json": 1_350, "markdown": 140}, + max_chars={"json": 55_000, "markdown": 10_000}, + max_lines={"json": 1_350, "markdown": 190}, ), CliOutputModeVariantSpec( variant_id="quota_should_run_user_todo_summary_detail", @@ -432,8 +433,8 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", - max_chars={"json": 35_000, "markdown": 9_000}, - max_lines={"json": 900, "markdown": 140}, + max_chars={"json": 35_000, "markdown": 10_000}, + max_lines={"json": 900, "markdown": 190}, ), CliOutputModeVariantSpec( variant_id="quota_should_run_goal_boundary_detail", @@ -442,8 +443,8 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", - max_chars={"json": 35_000, "markdown": 9_000}, - max_lines={"json": 900, "markdown": 140}, + max_chars={"json": 35_000, "markdown": 10_000}, + max_lines={"json": 900, "markdown": 190}, ), CliOutputModeVariantSpec( variant_id="quota_should_run_vision_detail", @@ -452,8 +453,8 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", - max_chars={"json": 55_000, "markdown": 9_000}, - max_lines={"json": 1_350, "markdown": 140}, + max_chars={"json": 55_000, "markdown": 10_000}, + max_lines={"json": 1_350, "markdown": 190}, ), CliOutputModeVariantSpec( variant_id="quota_should_run_all_detail", @@ -462,8 +463,8 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", - max_chars={"json": 85_000, "markdown": 9_000}, - max_lines={"json": 2_100, "markdown": 140}, + max_chars={"json": 85_000, "markdown": 10_000}, + max_lines={"json": 2_100, "markdown": 190}, ), CliOutputModeVariantSpec( variant_id="quota_should_run_turn_envelope", @@ -472,11 +473,12 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("schema_version", "contract_capsule", "action_signature"), markdown_anchor="# LoopX Turn Envelope", - # Complete work context costs 10,498 JSON chars (11,426 with enabled - # multi-subagent) and 3,949 Markdown chars on both pinned revisions. + # The same canonical fixture costs 10,681 JSON chars (12,268 with + # multi-subagent) on base/head. Rendering the instruction once reduces + # Markdown 4,996 -> 4,521 chars without removing source content. # These stdout guards do not change the envelope's wire byte limit. - max_chars={"json": 12_000, "markdown": 4_100}, - max_lines={"json": 300, "markdown": 95}, + max_chars={"json": 13_000, "markdown": 4_800}, + max_lines={"json": 300, "markdown": 140}, ), CliOutputModeVariantSpec( variant_id="loopx_turn_plan_transaction_detail", diff --git a/loopx/control_plane/todos/active_state_todos.py b/loopx/control_plane/todos/active_state_todos.py index 8e82d04e4e..920b6c328b 100644 --- a/loopx/control_plane/todos/active_state_todos.py +++ b/loopx/control_plane/todos/active_state_todos.py @@ -67,8 +67,6 @@ def active_state_todo_fields( load_rollout_events: Callable[..., list[dict[str, Any]]], rollout_event_log_path: Callable[[Path, str], Path], max_todo_index_rollout_events_per_goal: int, - parse_active_state_todos: Callable[..., dict[str, Any]], - active_state_incomplete_warning: Callable[[str], dict[str, Any] | None], parse_issue_meta_surface: Callable[[str], dict[str, Any] | None], backlog_hygiene_warning: Callable[..., dict[str, Any] | None], completed_todo_archive_warning: Callable[[dict[str, Any] | None], dict[str, Any] | None], @@ -93,29 +91,17 @@ def active_state_todo_fields( # Inspect authority before the display file. A missing/stale projection is # not an empty Todo collection, and an unavailable provider must fail closed. canonical_reader = todo_snapshot.read if todo_snapshot is not None else read_canonical_todos_if_promoted - canonical = ( - canonical_reader(runtime_root=runtime_root, goal_id=goal_id) - if runtime_root is not None and goal_id else None - ) + if runtime_root is None or not goal_id: + raise ValueError("canonical Todo status requires a runtime root and Goal identity") + canonical = canonical_reader(runtime_root=runtime_root, goal_id=goal_id) state_path = resolve_goal_local_path(goal.get("state_file"), goal, fallback_base=Path.cwd()) - if canonical is None and state_path is not None: - from ..goals.legacy_event_source import require_no_legacy_todo_events - require_no_legacy_todo_events(goal, state_path=state_path) - if canonical is None and (state_path is None or not state_path.exists()): - return {} try: state_text = state_path.read_text(encoding="utf-8") if state_path is not None else "" except OSError: - if canonical is None: - return {} state_text = "" except UnicodeError: - if canonical is None: - raise state_text = "" next_action_entries = active_state_next_action_entries(state_text, limit=3) - from .next_action_runtime import bound_next_action_todo_ids - preferred_todo_ids = bound_next_action_todo_ids(state_text) events = ( [dict(event) for event in rollout_events] if rollout_events is not None @@ -128,36 +114,18 @@ def active_state_todo_fields( ) if runtime_root is not None and goal_id: events = TodoResumeRolloutEvents(events, runtime_root=runtime_root, goal_id=goal_id) - if canonical is not None: - from ..goals.acceptance import observe_canonical_goal_acceptance - acceptance = observe_canonical_goal_acceptance(canonical, - registry_path=source_registry, goal_id=goal_id) - fields = canonical_todo_summary_fields( - canonical["todos"], - rollout_events=events, - goal_acceptance_contract=acceptance, - goal_acceptance_work_guards=canonical.get( - "goal_acceptance_work_guards" - ), - ) - # Canonical observation/successor transactions now support current - # lease proof. Scheduling exposes due work; mutation admission still - # validates the caller's proof and never falls back to the old writer. - else: - fields = parse_active_state_todos( - state_text, - goal=goal, - state_path=state_path, - preferred_todo_ids=preferred_todo_ids, - rollout_events=events, - ) - state_incomplete = active_state_incomplete_warning(state_text) - if state_incomplete: - fields["state_incomplete"] = state_incomplete + from ..goals.acceptance import observe_canonical_goal_acceptance + acceptance = observe_canonical_goal_acceptance(canonical, + registry_path=source_registry, goal_id=goal_id) + fields = canonical_todo_summary_fields( + canonical["todos"], rollout_events=events, + goal_acceptance_contract=acceptance, + goal_acceptance_work_guards=canonical.get("goal_acceptance_work_guards"), + ) # An explicit canonical objective revision owns current intent. A scoped # check objective still cannot substitute for Goal intent. Keep this status # field as display content; admission uses the native revision and guard. - revised_objective = ((acceptance or {}).get("goal_objective") or {}).get("objective") if canonical is not None else None + revised_objective = ((acceptance or {}).get("goal_objective") or {}).get("objective") fields["goal_objective"] = public_safe_compact_text( revised_objective or parse_state_frontmatter(state_text).get("objective"), limit=1000, ) @@ -169,18 +137,15 @@ def active_state_todo_fields( fields["active_state_next_action_entries"] = next_action_entries if goal_id and admission_goal is not None: source = recommendation_source_context(admission_goal, state_text, - source_registry=source_registry, todo_fields=fields if canonical is not None else None) + source_registry=source_registry, todo_fields=fields) fields["recommendation_context"] = source sole_agent = len(registered_agent_ids_for_goal(admission_goal)) == 1 # Default multi-agent status needs source facts, not an RPC and full # journal scan per peer. Detail and sole-agent readback request routes. if include_agent_next_actions or sole_agent: # Binding uses full task facts, never a bounded display page. - route_fields = fields if canonical is not None else parse_active_state_todos( - state_text, goal=goal, state_path=state_path, rollout_events=events, item_limit=None, - ) runs = recommendation_runs(runtime_root, goal_id) if runtime_root else [] - routes = project_agent_next_actions(goal, route_fields, source=source, runs=runs) + routes = project_agent_next_actions(goal, fields, source=source, runs=runs) if sole_agent and routes: fields["next_action_basis"] = routes[0]["next_action_basis"] if include_agent_next_actions and routes: diff --git a/loopx/control_plane/todos/list_readback.py b/loopx/control_plane/todos/list_readback.py index e7e3a11a80..c01e754fc6 100644 --- a/loopx/control_plane/todos/list_readback.py +++ b/loopx/control_plane/todos/list_readback.py @@ -18,15 +18,10 @@ canonical_todo_summary_fields, read_canonical_todos_if_promoted, ) -from .active_state_todo_parser import ( - active_state_incomplete_warning, - parse_todo_source, -) from .contract import normalize_todo_claimed_by, normalize_todo_id, normalize_todo_status from .goal_todo_projection import ( exact_archived_todo_summaries, retained_todo_summary_fields, - goal_todo_summaries, todo_summaries_from_fields, ) from .list_projection import ( @@ -109,55 +104,28 @@ def list_goal_todos( runtime_root=runtime_root, goal_id=goal_id, ) - state_incomplete: dict[str, Any] | None = None - if canonical_read is not None: - projected = todo_summaries_from_fields( - fields=(retained_todo_summary_fields( - canonical_todo_items(canonical_read["todos"]), rollout_events=rollout_events, - ) if read_scope == "completed_history" else canonical_todo_summary_fields( - canonical_read["todos"], - rollout_events=rollout_events, - goal_acceptance_contract=canonical_read.get("goal_acceptance_contract"), - goal_acceptance_work_guards=canonical_read.get("goal_acceptance_work_guards"), - )), - source="file_authority", + projected = todo_summaries_from_fields( + fields=(retained_todo_summary_fields( + canonical_todo_items(canonical_read["todos"]), rollout_events=rollout_events, + ) if read_scope == "completed_history" else canonical_todo_summary_fields( + canonical_read["todos"], rollout_events=rollout_events, - roles=roles, - status=status, - todo_id=normalized_todo_id, - agent_id=normalized_agent_id, - limit=limit, - ) - else: - if not resolved_state_file.exists(): - raise ValueError(f"active state file does not exist: {resolved_state_file}") - state_text = resolved_state_file.read_text(encoding="utf-8") - state_incomplete = active_state_incomplete_warning(state_text) - projected = goal_todo_summaries( - goal, - state_text=state_text, - state_path=resolved_state_file, - rollout_events=rollout_events, - roles=roles, - status=status, - todo_id=normalized_todo_id, - agent_id=normalized_agent_id, - limit=limit, - include_retained=read_scope == "completed_history", - ) + goal_acceptance_contract=canonical_read.get("goal_acceptance_contract"), + goal_acceptance_work_guards=canonical_read.get("goal_acceptance_work_guards"), + )), + source="file_authority", + rollout_events=rollout_events, + roles=roles, + status=status, + todo_id=normalized_todo_id, + agent_id=normalized_agent_id, + limit=limit, + ) if read_scope == "active" and normalized_todo_id and not projected.todos: - if canonical_read is not None: - archived_items = [ - item - for item in canonical_todo_items(canonical_read["todos"]) - if item.get("archive_state") == "archive" - ] - else: - _active_items, archived_items, _source_sections = parse_todo_source( - state_text, - goal=goal, - state_path=resolved_state_file, - ) + archived_items = [ + item for item in canonical_todo_items(canonical_read["todos"]) + if item.get("archive_state") == "archive" + ] archived_projection = exact_archived_todo_summaries( archived_items=archived_items, source=projected.source, @@ -177,14 +145,7 @@ def list_goal_todos( # evaluated identity/status/guards. List and thin projections stay bounded; # a hot summary is never treated as the original request. if normalized_todo_id and not thin and len(todos) == 1: - if canonical_read is not None: - source_items = canonical_read["todos"] - else: - active, archived, _sections = parse_todo_source( - state_text, goal=goal, state_path=resolved_state_file, - ) - source_items = [*active["user"], *active["agent"], *archived] - _restore_full_source_body(todos[0], normalized_todo_id, source_items) + _restore_full_source_body(todos[0], normalized_todo_id, canonical_read["todos"]) unfiltered_count = projected.unfiltered_count uncapped_todo_count = projected.uncapped_todo_count @@ -225,24 +186,14 @@ def list_goal_todos( "state_file": str(resolved_state_file), "project": str(resolved_project) if resolved_project else None, } - if state_incomplete: - payload["ok"] = False - payload["error_code"] = "state_incomplete" - payload["error"] = ( - "active state is structurally incomplete; missing required Todo " - f"section(s): {', '.join(state_incomplete['missing_sections'])}; " - f"{state_incomplete['recommended_action']}" - ) - payload["state_incomplete"] = state_incomplete - if canonical_read is not None: - payload["authority_read"] = { - "source_authority": canonical_read["source_authority"], - "provider_revision": canonical_read.get("provider_revision"), - "cursor": canonical_read.get("cursor"), - "todo_read_model": canonical_read.get("todo_read_model"), - "decision_read_from_provider": True, - "legacy_fallback_used": False, - } + payload["authority_read"] = { + "source_authority": canonical_read["source_authority"], + "provider_revision": canonical_read.get("provider_revision"), + "cursor": canonical_read.get("cursor"), + "todo_read_model": canonical_read.get("todo_read_model"), + "decision_read_from_provider": True, + "legacy_fallback_used": False, + } if normalized_agent_id: payload["agent_id_filter"] = normalized_agent_id payload["unfiltered_todo_count"] = unfiltered_count diff --git a/loopx/control_plane/todos/provider_handoff_mode.py b/loopx/control_plane/todos/provider_handoff_mode.py index c79ee6b523..d7fc3800b1 100644 --- a/loopx/control_plane/todos/provider_handoff_mode.py +++ b/loopx/control_plane/todos/provider_handoff_mode.py @@ -15,10 +15,8 @@ from ..runtime.time import now_local_iso -def read_canonical_handoff_mode(*, runtime_root: Path, goal_id: str) -> dict[str, Any] | None: +def read_canonical_handoff_mode(*, runtime_root: Path, goal_id: str) -> dict[str, Any]: source = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id, include_leases=True) - if source is None: - return None return {key: source[key] for key in ("handoff_mode", "source_authority", "provider_revision", "decision_read_from_provider", "legacy_fallback_used")} diff --git a/loopx/control_plane/work_items/attention_queue.py b/loopx/control_plane/work_items/attention_queue.py index b28ca6ef2b..9bfbfdf99b 100644 --- a/loopx/control_plane/work_items/attention_queue.py +++ b/loopx/control_plane/work_items/attention_queue.py @@ -7,6 +7,7 @@ from .replan_history_codec import effective_turn_cadence_context from ..goals.legacy_event_source import RetiredTodoEventSourceError +from ..coordination.local_authority import LocalCoordinationAuthorityUnavailable from ..effect_runtime import effect_runtime_result @@ -156,6 +157,28 @@ def _active_state_goal_context( return fields, context.active_state_todo_attention_item(goal, fields, current_run) +def _unavailable_todo_source_item( + goal: dict[str, Any], + error: RetiredTodoEventSourceError | LocalCoordinationAuthorityUnavailable, + context: AttentionQueueContext, +) -> dict[str, Any]: + item = context.attention_item( + goal_id=str(goal.get("id") or ""), + status=(error.code if isinstance(error, LocalCoordinationAuthorityUnavailable) + else error.reason_code), + waiting_on="user_or_controller", + severity="high", + recommended_action=str(error), + source="active_state", + ) + item["todo_source"] = "unavailable" + if isinstance(error, LocalCoordinationAuthorityUnavailable): + item["error_code"] = error.code + item["authority_read"] = error.payload + item["activation_state"] = str(goal.get("activation_state") or "active") + return item + + def build_attention_queue( *, contract: dict[str, Any], @@ -205,20 +228,12 @@ def build_attention_queue( active_state_fields, active_state_item = _active_state_goal_context( goal, current_status_run, context, runtime_root, ) - except RetiredTodoEventSourceError as error: + except (RetiredTodoEventSourceError, LocalCoordinationAuthorityUnavailable) as error: + if isinstance(error, LocalCoordinationAuthorityUnavailable) and goal_id_filter: + raise # Refuse this source without hiding independent Goals or turning # stale Todo/run projections into executable fallback work. - item = context.attention_item( - goal_id=str(goal.get("id") or ""), - status=error.reason_code, - waiting_on="user_or_controller", - severity="high", - recommended_action=str(error), - source="active_state", - ) - item["todo_source"] = "unavailable" - item["activation_state"] = str(goal.get("activation_state") or "active") - history_items.append(item) + history_items.append(_unavailable_todo_source_item(goal, error, context)) continue if active_state_item and active_state_item.get("waiting_on") in {"controller", "user_or_controller"}: item = active_state_item diff --git a/loopx/control_plane/work_items/canonical_task_lease_lifecycle.ts b/loopx/control_plane/work_items/canonical_task_lease_lifecycle.ts index 38d8496cd4..8e75696da1 100644 --- a/loopx/control_plane/work_items/canonical_task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/canonical_task_lease_lifecycle.ts @@ -5,7 +5,7 @@ import {openLocalAuthorityStoreHandle, localAuthorityOpenFailure, type LocalAuthorityProviderDependencies} from "../coordination/local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence} from "../coordination/legacy_writer_fence.ts"; import { - canonicalWriteRouteFailure, + canonicalAuthorityRouteFailure, withCanonicalWriter, type CanonicalBusinessWrite, } from "../coordination/local_authority_write.ts"; @@ -95,7 +95,7 @@ export async function withCanonicalTaskLeaseAuthority( if (error instanceof ShadowManagementError || error instanceof TaskLeaseAcquireError) return {...rejected(error.code, error.message), ...error.payload}; if (error instanceof EffectRuntimeLockTimeoutError) return rejected("lock_acquire_timeout", "canonical lease maintenance lock timed out"); return {...rejected("canonical_lease_route_failed", error instanceof Error ? error.message : "canonical lease route failed"), - ...localAuthorityOpenFailure(error), ...canonicalWriteRouteFailure(error)}; + ...localAuthorityOpenFailure(error), ...canonicalAuthorityRouteFailure(error)}; } } diff --git a/loopx/control_plane/work_items/context_readback.py b/loopx/control_plane/work_items/context_readback.py index 631882f4f4..e92941afde 100644 --- a/loopx/control_plane/work_items/context_readback.py +++ b/loopx/control_plane/work_items/context_readback.py @@ -14,13 +14,11 @@ from ..coordination.local_authority import read_canonical_todos_if_promoted from ..goals.acceptance import inspect_goal_acceptance from ..goals.state_resolution import resolve_goal_state -from ..todos.active_state_todo_parser import parse_todo_source from ..todos.list_readback import list_goal_todos from ..todos.summary_item import todo_text_content_revision -def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id, - state_file=None, expected_state_revision=None): +def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id): source = read.get("source") if source == "goal_state": _goal, _project, state_file = resolve_goal_state( @@ -42,33 +40,14 @@ def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id, raise ValueError("selected Todo identity is unavailable") canonical = read_canonical_todos_if_promoted( runtime_root=runtime_root, goal_id=goal_id) - if canonical is not None: - records = canonical["todos"] - source_name = canonical["source_authority"] - authority_read = { - "source_authority": source_name, - "provider_revision": canonical.get("provider_revision"), - "decision_read_from_provider": True, - "legacy_fallback_used": False, - } - else: - if not isinstance(state_file, Path): - raise ValueError("selected Todo source requires the current Goal state read") - source_bytes = state_file.read_bytes() - revision = "sha256:" + sha256(source_bytes).hexdigest() - if expected_state_revision and expected_state_revision != revision: - raise ValueError("selected Todo source changed after the Goal state read") - state_text = source_bytes.decode("utf-8") - active, archived, _sections = parse_todo_source( - state_text, state_path=state_file) - records = [*active["user"], *active["agent"], *archived] - source_name = "markdown_active_state" - authority_read = { - "source_authority": source_name, - "provider_revision": revision, - "decision_read_from_provider": False, - "legacy_fallback_used": False, - } + records = canonical["todos"] + source_name = canonical["source_authority"] + authority_read = { + "source_authority": source_name, + "provider_revision": canonical.get("provider_revision"), + "decision_read_from_provider": True, + "legacy_fallback_used": False, + } matches = [item for item in records if item.get("todo_id") == todo_id and item.get("archive_state", "active") != "archive"] @@ -131,24 +110,16 @@ def attach_work_context(payload: dict[str, Any], *, registry_path: Path, if isinstance(selected, dict) and isinstance(selected_text_snapshot, str): selected["_context_text_sha256"] = selected_text_snapshot results = [dict(item) for item in (hook_dispatch or {}).get("contexts", [])] - state_file = None - state_revision = None for read in reads: if read.get("source") not in {"goal_state", "goal_acceptance", "selected_todo"}: continue result = {"command": read["command"]} try: result["content"] = _source_content(read, registry_path=registry_path, - runtime_root=runtime_root, goal_id=goal_id, todo_id=plan.get("todo_id"), - state_file=state_file, expected_state_revision=state_revision) + runtime_root=runtime_root, goal_id=goal_id, todo_id=plan.get("todo_id")) except (OSError, ValueError, RuntimeError): result["error_code"] = "context_source_unavailable" results.append(result) - if read.get("source") == "goal_state" and isinstance(result.get("content"), dict): - resolved_path = result["content"].get("state_file") - if isinstance(resolved_path, str): - state_file = Path(resolved_path) - state_revision = result["content"].get("revision") users = None if agent_id and plan.get("read_user_todos"): try: diff --git a/loopx/control_plane/work_items/refresh_recommendation.py b/loopx/control_plane/work_items/refresh_recommendation.py index df8be91e66..395720d3b9 100644 --- a/loopx/control_plane/work_items/refresh_recommendation.py +++ b/loopx/control_plane/work_items/refresh_recommendation.py @@ -8,7 +8,6 @@ from ..agents.agent_lane_recommendation import build_agent_lane_next_action from ..coordination.local_authority import read_canonical_todos_if_promoted, canonical_todo_summary_fields from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result -from ..todos.active_state_todo_parser import parse_active_state_todos from ..todos.contract import normalize_todo_id from ...feedback import validate_local_control_text from ...state_projection import active_state_next_action_entries @@ -31,8 +30,8 @@ class RefreshPlanningSource: state_text: str events: list[dict[str, Any]] - todo_fields: dict[str, Any] | None - canonical_snapshot: dict[str, Any] | None + todo_fields: dict[str, Any] + canonical_snapshot: dict[str, Any] def load_refresh_planning_source( @@ -50,25 +49,24 @@ def load_refresh_planning_source( """ events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id)) canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) - if canonical is not None: - from ..goals.acceptance import observe_canonical_goal_acceptance - oracle_registry = registry_path - if canonical.get("goal_acceptance_oracle_observation_basis") is not None and oracle_registry is not None: - from .recommendation_source_io import load_recommendation_source_goal - # A shared roster's checkout is display data, including on refresh - # paths without --next-action. Observe the current source workspace. - oracle_registry, _ = load_recommendation_source_goal(oracle_registry, goal_id) - acceptance = observe_canonical_goal_acceptance(canonical, - registry_path=oracle_registry, goal_id=goal_id) + from ..goals.acceptance import observe_canonical_goal_acceptance + oracle_registry = registry_path + if canonical.get("goal_acceptance_oracle_observation_basis") is not None and oracle_registry is not None: + from .recommendation_source_io import load_recommendation_source_goal + # A shared roster's checkout is display data, including on refresh + # paths without --next-action. Observe the current source workspace. + oracle_registry, _ = load_recommendation_source_goal(oracle_registry, goal_id) + acceptance = observe_canonical_goal_acceptance(canonical, + registry_path=oracle_registry, goal_id=goal_id) fields = canonical_todo_summary_fields( canonical["todos"], rollout_events=events, goal_acceptance_contract=acceptance, goal_acceptance_work_guards=canonical.get("goal_acceptance_work_guards"), - ) if canonical is not None else None + ) try: text = state_path.read_text(encoding="utf-8") except FileNotFoundError: - if fields is None or require_display: + if require_display: raise FileNotFoundError(f"state file does not exist: {state_path}") from None text = "" return RefreshPlanningSource(text, events, fields, canonical) @@ -84,28 +82,6 @@ def _first_valid_action(values: list[str]) -> str | None: return None -def _agent_todo_summary( - state_text: str, - *, - registry_goal: dict[str, Any] | None, - state_path: Path | None, - settlement_todo_id: str | None, - rollout_events: list[dict[str, Any]] | None, - todo_fields: dict[str, Any] | None, -) -> dict[str, Any] | None: - preferred = {settlement_todo_id} if settlement_todo_id else None - parsed = todo_fields if todo_fields is not None else parse_active_state_todos( - state_text, - goal=registry_goal, - state_path=state_path, - preferred_todo_ids=preferred, - rollout_events=rollout_events, - item_limit=None, - ) - summary = parsed.get("agent_todos") - return summary if isinstance(summary, dict) else None - - def _exact_todo( summary: Mapping[str, Any] | None, todo_id: str | None, @@ -171,14 +147,8 @@ def resolve_refresh_recommendation( text_limit=500, ) shared_action = _first_valid_action(next_action_entries) - summary = _agent_todo_summary( - state_text, - registry_goal=registry_goal, - state_path=state_path, - settlement_todo_id=settlement_todo_id, - rollout_events=rollout_events, - todo_fields=todo_fields, - ) + observed = todo_fields.get("agent_todos") if todo_fields is not None else None + summary = observed if isinstance(observed, dict) else None lane_candidate = build_agent_lane_next_action( agent_identity={"agent_id": agent_id} if agent_id else None, agent_todo_summary=summary, diff --git a/loopx/control_plane/work_items/task_lease_acquire_adapter.py b/loopx/control_plane/work_items/task_lease_acquire_adapter.py index 961492aafe..2b73f076ac 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_adapter.py +++ b/loopx/control_plane/work_items/task_lease_acquire_adapter.py @@ -581,31 +581,25 @@ def inspect_native_task_lease( """Project source facts; the typed reader owns lease interpretation.""" from ..coordination.local_authority import ( LOCAL_AUTHORITY_SOURCES, LocalCoordinationAuthorityUnavailable, - local_authority_is_promoted, ) from ..effect_runtime import ( CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS, - DEFAULT_REQUEST_TIMEOUT_SECONDS, effect_runtime_result, ) for attempt in range(TASK_LEASE_AUTHORITY_SNAPSHOT_ATTEMPTS): - canonical = local_authority_is_promoted(runtime_root=runtime_root, goal_id=goal_id) - # Source records are retained history, never a current execution policy. - # No retired Todo projection is needed to report them read-only. - authority = _canonical_lease_authority_facts(registry_path, goal_id) if canonical else task_lease_acquire_authority_facts( - registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, - ) + # Migration inventory reads retained source leases through its own owner. + # Ordinary inspection never selects the legacy Todo/lease source. + authority = _canonical_lease_authority_facts(registry_path, goal_id) request = { "schema_version": "loopx_task_lease_inspect_request_v0", - "source": "canonical" if canonical else "legacy", + "source": "canonical", "runtime_root": str(runtime_root.resolve()), "goal_id": goal_id, "todo_id": todo_id, "authority": authority, } result = effect_runtime_result( "task_lease.inspect.native", request, - timeout=(CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS - if canonical else DEFAULT_REQUEST_TIMEOUT_SECONDS), + timeout=CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS, ) if not isinstance(result, dict) or result.get("schema_version") != TASK_LEASE_SCHEMA_VERSION or result.get("action") != "inspect" or not isinstance(result.get("ok"), bool): raise RuntimeError("native lease inspection result shape mismatch") @@ -613,13 +607,13 @@ def inspect_native_task_lease( continue if result.get("ok") is not True: code = str(result.get("error_code") or "task_lease_inspection_unavailable") - exception = LocalCoordinationAuthorityUnavailable if canonical and code != "corrupt_lease" else TaskLeaseError + exception = LocalCoordinationAuthorityUnavailable if code != "corrupt_lease" else TaskLeaseError raise exception(str(result.get("error") or "lease inspection unavailable"), code=code, payload=result) - if not isinstance(result.get("active"), bool) or (canonical and ( + if not isinstance(result.get("active"), bool) or ( result.get("source_authority") not in LOCAL_AUTHORITY_SOURCES or not isinstance(result.get("provider_revision"), str) or result.get("legacy_fallback_used") is not False - )): + ): raise RuntimeError("native lease inspection omitted source evidence") return result raise RuntimeError("native lease inspection exhausted source retries") diff --git a/loopx/control_plane/work_items/task_lease_inspection.ts b/loopx/control_plane/work_items/task_lease_inspection.ts index e5ed6dea57..5d848ff118 100644 --- a/loopx/control_plane/work_items/task_lease_inspection.ts +++ b/loopx/control_plane/work_items/task_lease_inspection.ts @@ -15,6 +15,7 @@ import {retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "../coord import {leaseOwnerConstraint} from "./task_lease_eligibility.ts"; import {leaseRepositoryRejection} from "./task_lease_repository.ts"; import {todoExecutionDependencyRejection} from "../coordination/todo_execution_dependency.ts"; +import {canonicalAuthorityRouteFailure, requireCanonicalAuthority} from "../coordination/local_authority_write.ts"; export const TASK_LEASE_INSPECT_REQUEST = "loopx_task_lease_inspect_request_v0"; @@ -43,6 +44,9 @@ export async function inspectTaskLease(value: unknown, const root = input.runtime_root, canonical = input.source === "canonical"; if (canonical) evidence = {source_authority: null, legacy_fallback_used: false}; const beforeFence = await loadLegacyCoordinationWriterFence(root, goalId); + if (canonical && beforeFence.status !== "loaded") { + await requireCanonicalAuthority(root, goalId, "task_lease_inspect"); + } if (beforeFence.status === "failed") throw new TaskLeaseAcquireError(beforeFence.reason, beforeFence.reason_code); if ((beforeFence.status === "loaded") !== canonical) { throw new TaskLeaseAcquireError("lease authority route changed; retry inspection", "authority_source_changed"); @@ -114,10 +118,12 @@ export async function inspectTaskLease(value: unknown, ...(dependency === null ? {} : {resume_condition: dependency.condition})}; } catch (error) { const opening = localAuthorityOpenFailure(error); + const routing = canonicalAuthorityRouteFailure(error); return {ok: false, schema_version: TASK_LEASE_SCHEMA_VERSION, action: "inspect", goal_id: goalId, todo_id: todoId, ...evidence, - ...(error instanceof TaskLeaseAcquireError ? error.payload : {}), ...opening, - error_code: typeof opening.reason_code === "string" ? opening.reason_code + ...(error instanceof TaskLeaseAcquireError ? error.payload : {}), ...opening, ...routing, + error_code: typeof routing.reason_code === "string" ? routing.reason_code + : typeof opening.reason_code === "string" ? opening.reason_code : error instanceof TaskLeaseAcquireError ? error.code : error instanceof AuthorityStoreProtocolError ? "local_authority_snapshot_invalid" : "task_lease_inspection_unavailable", error: error instanceof Error ? error.message : "task lease inspection unavailable"}; diff --git a/loopx/control_plane/work_items/team_plan_authority.ts b/loopx/control_plane/work_items/team_plan_authority.ts index cf260c3966..eb4e971a1d 100644 --- a/loopx/control_plane/work_items/team_plan_authority.ts +++ b/loopx/control_plane/work_items/team_plan_authority.ts @@ -13,8 +13,8 @@ import {type AuthoritySourceCheck, uncheckedAuthoritySource} from "../coordinati import {legacyCoordinationTodoLockPath} from "../coordination/legacy_writer_lock_paths.ts"; import {requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath} from "../coordination/shadow_management.ts"; import { - canonicalWriteRouteFailure, - requireCanonicalBusinessWrite, + canonicalAuthorityRouteFailure, + requireCanonicalAuthority, } from "../coordination/local_authority_write.ts"; import {claimFileMutationLock, mutationLockOwner, releaseFileMutationLock, releaseFileMutationLockClaim, type FileMutationLockClaim} from "../effect_runtime_io.ts"; @@ -91,7 +91,7 @@ export async function commitLocalTeamPlan(value: unknown): Promise { if (i === witnesses.length - 1) adopted = true; } await requireShadowPrimaryWriteAllowed(root, goal); - await requireCanonicalBusinessWrite(root, goal, "team_plan"); + await requireCanonicalAuthority(root, goal, "team_plan"); const store = await openLocalAuthorityStore(root, goal); const result = await commitTeamPlan(store, request, async () => { // Exact committed receipts replay before these admission checks. An @@ -108,7 +108,7 @@ export async function commitLocalTeamPlan(value: unknown): Promise { } catch (error) { return {status: "failed", reason_code: error instanceof EffectRuntimeRequestError ? error.code : "team_plan_commit_failed", reason: error instanceof Error ? error.message : String(error), - ...localAuthorityOpenFailure(error), ...canonicalWriteRouteFailure(error)}; + ...localAuthorityOpenFailure(error), ...canonicalAuthorityRouteFailure(error)}; } finally { // Claims keep the source markers alive even after a requesting Python // process exits. The native effect releases the handed-off critical section. diff --git a/loopx/presentation/renderers/turn_envelope_markdown.py b/loopx/presentation/renderers/turn_envelope_markdown.py index 9a3e77ed34..756779f34f 100644 --- a/loopx/presentation/renderers/turn_envelope_markdown.py +++ b/loopx/presentation/renderers/turn_envelope_markdown.py @@ -19,8 +19,9 @@ def work_context_lines(context: dict[str, Any]) -> list[str]: if isinstance(instruction, str) and instruction: lines.append(f"- work_context_instruction: {instruction}") return lines + details = {key: value for key, value in context.items() if key != "instruction"} return ["", "## Current work context", "", context.get("instruction", ""), - "", "```json", json.dumps(context, ensure_ascii=False, indent=2), "```", ""] + "", "```json", json.dumps(details, ensure_ascii=False, indent=2), "```", ""] def turn_envelope_budget_warning_lines(payload: dict[str, Any]) -> list[str]: diff --git a/loopx/status.py b/loopx/status.py index 3da09b62df..2a1df04efc 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -127,13 +127,15 @@ from .control_plane.goals.active_state_metadata import ( parse_state_frontmatter, ) +# Explicit source parsing remains available to migration and fixture callers; +# ordinary status obtains Task authority through the canonical read model. +from .control_plane.todos.active_state_todo_parser import ( + active_state_incomplete_warning as active_state_incomplete_warning, + parse_active_state_todos as parse_active_state_todos, +) from .control_plane.todos.active_state_todos import ( active_state_todo_fields as _active_state_todo_fields_read_model, ) -from .control_plane.todos.active_state_todo_parser import ( - active_state_incomplete_warning, - parse_active_state_todos, -) from .control_plane.work_items.attention_item import ( attention_item as _attention_item_read_model, ) @@ -731,8 +733,6 @@ def active_state_todo_fields( load_rollout_events=load_rollout_events, rollout_event_log_path=rollout_event_log_path, max_todo_index_rollout_events_per_goal=MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL, - parse_active_state_todos=parse_active_state_todos, - active_state_incomplete_warning=active_state_incomplete_warning, parse_issue_meta_surface=parse_issue_meta_surface, backlog_hygiene_warning=backlog_hygiene_warning, completed_todo_archive_warning=completed_todo_archive_warning, diff --git a/tests/control_plane/test_canonical_goal_channel_coordination.py b/tests/control_plane/test_canonical_goal_channel_coordination.py index 63ff65cbb6..9faaef9d42 100644 --- a/tests/control_plane/test_canonical_goal_channel_coordination.py +++ b/tests/control_plane/test_canonical_goal_channel_coordination.py @@ -36,7 +36,7 @@ def test_empty_canonical_ownership_never_revives_display_claim_or_local_lease(tm def test_explicit_empty_observation_is_not_unspecified(): observed = build_goal_channel_projection(goal_id=GOAL_ID, status_item=_status_item(claimed_by='stale-agent'), active_leases=[]) assert observed['active_leases'] == [] - assert observed['coordination_authority']['state'] == 'promotion_required' + assert observed['coordination_authority']['state'] == 'unavailable' assert observed['mode'] == 'read_only' assert observed['truth_contract']['write_authority'] == 'none' diff --git a/tests/control_plane/test_canonical_status_todos.py b/tests/control_plane/test_canonical_status_todos.py index 46d1bc9959..8dfd6d41f8 100644 --- a/tests/control_plane/test_canonical_status_todos.py +++ b/tests/control_plane/test_canonical_status_todos.py @@ -106,7 +106,7 @@ def test_status_uses_provider_and_allows_native_monitor_writeback_without_displa state.unlink() for name in ("parse_active_state_todos",): monkeypatch.setattr( - f"loopx.status.{name}", + f"loopx.control_plane.todos.active_state_todo_parser.{name}", lambda *_args, **_kwargs: pytest.fail("promoted read must not parse legacy Todos"), ) fields = active_state_todo_fields(goal, runtime_root=runtime) @@ -148,17 +148,45 @@ def test_empty_canonical_collection_is_an_explicit_empty_read_model(tmp_path): assert fields[f"{role}_todos"]["total_count"] == 0 -def test_unpromoted_status_retains_markdown_without_starting_authority(tmp_path, monkeypatch): - monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority.effect_runtime_result", - lambda *_args, **_kwargs: pytest.fail("unpromoted reads must not start TS authority"), - ) +def test_global_status_keeps_unmigrated_goal_visible_without_hiding_canonical_work( + promoted_goal, tmp_path, +): + from loopx.status import collect_status + + goal, runtime, _ = promoted_goal + old_state = tmp_path / "legacy.md" + old_state.write_text("# Goal\n\n## Agent Todo\n\n- [ ] Obsolete work\n") + unmigrated = {**goal, "id": "legacy-goal", "state_file": str(old_state)} + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "goals": [goal, unmigrated], + "common_runtime_root": str(runtime)})) + payload = collect_status(registry_path=registry, runtime_root_override=str(runtime), limit=5) + items = {item["goal_id"]: item for item in payload["attention_queue"]["items"]} + assert items[goal["id"]]["agent_todos"]["items"][0]["todo_id"] == "todo_canonical" + refused = items["legacy-goal"] + assert refused["error_code"] == "canonical_authority_required" + assert refused["todo_source"] == "unavailable" + assert refused["authority_read"]["legacy_fallback_used"] is False + assert "agent_todos" not in refused + assert "Obsolete work" in old_state.read_text() + + +@pytest.mark.parametrize("display_exists", [False, True]) +def test_unmigrated_status_refuses_markdown_before_reading_it(tmp_path, monkeypatch, display_exists): state = tmp_path / "state.md" goal = {"id": "goal-a", "repo": str(tmp_path), "state_file": str(state)} - assert active_state_todo_fields(goal, runtime_root=tmp_path / "runtime") == {} - state.write_text("# Goal\n\n## Agent Todo\n\n- [ ] Legacy work\n") - fields = active_state_todo_fields(goal, runtime_root=tmp_path / "runtime") - assert fields["agent_todos"]["items"][0]["text"] == "Legacy work" + if display_exists: + state.write_text("# Goal\n\n## Agent Todo\n\n- [ ] Legacy work\n") + original = state.read_bytes() if display_exists else None + monkeypatch.setattr( + "loopx.control_plane.todos.active_state_todo_parser.parse_active_state_todos", + lambda *_args, **_kwargs: pytest.fail("ordinary status must never parse legacy Todos"), + ) + with pytest.raises(LocalCoordinationAuthorityUnavailable) as error: + active_state_todo_fields(goal, runtime_root=tmp_path / "runtime") + assert error.value.code == "canonical_authority_required" + assert error.value.payload["legacy_fallback_used"] is False + assert (state.read_bytes() if state.exists() else None) == original @pytest.mark.parametrize( diff --git a/tests/control_plane/test_canonical_todo_request_snapshot.py b/tests/control_plane/test_canonical_todo_request_snapshot.py index 94f040449f..37cef2531f 100644 --- a/tests/control_plane/test_canonical_todo_request_snapshot.py +++ b/tests/control_plane/test_canonical_todo_request_snapshot.py @@ -32,8 +32,8 @@ def read(**kwargs): assert authority.CanonicalTodoSnapshot().read(**args)['provider_revision'] == 'rev-b' -@pytest.mark.parametrize('initial', [None, {'todos': [], 'provider_revision': 'empty'}]) -def test_empty_and_unpromoted_observations_are_not_cache_misses(tmp_path, monkeypatch, initial): +def test_empty_canonical_observations_are_not_cache_misses(tmp_path, monkeypatch): + initial = {'todos': [], 'provider_revision': 'empty'} calls = [] def read(**kwargs): calls.append(kwargs) diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 174659cfb1..e928757102 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -15,6 +15,7 @@ from loopx.cli import main as cli_main from loopx.control_plane.scheduler.execution_context import SchedulerRuntimeProfile +from loopx.control_plane.testing.canary_harness import import_fixture_authority from loopx.control_plane.testing.cli_output_budget import ( CLI_OUTPUT_BUDGET_BY_ID, CLI_OUTPUT_BUDGET_SPECS, @@ -454,6 +455,7 @@ def _measure_scenario(root: Path, scenario: Scenario) -> dict[str, dict[str, dic stable_root, scenario, ) + import_fixture_authority(registry_path, GOAL_ID) results: dict[str, dict[str, dict]] = {} for output_format in ("json", "markdown"): commands = _surface_commands( @@ -840,6 +842,7 @@ def test_diagnose_keeps_selected_reads_once_and_out_of_the_goal_overview( ) -> None: with _stable_budget_fixture_root(tmp_path / "diagnose-selected-read") as stable_root: project, runtime, registry_path, state_file = _write_fixture(stable_root, SCENARIOS[0]) + import_fixture_authority(registry_path, GOAL_ID) command = _surface_commands( project=project, runtime=runtime, @@ -860,6 +863,7 @@ def test_quota_packet_drops_transient_path_but_keeps_verified_todo_reference( ) -> None: with _stable_budget_fixture_root(tmp_path / "quota-goal-read") as stable_root: project, runtime, registry_path, state_file = _write_fixture(stable_root, SCENARIOS[0]) + import_fixture_authority(registry_path, GOAL_ID) command = _surface_commands( project=project, runtime=runtime, @@ -880,6 +884,7 @@ def test_turn_envelope_references_selected_todo_without_duplicate_context( ) -> None: with _stable_budget_fixture_root(tmp_path / "turn-envelope-selected-read") as stable_root: project, runtime, registry_path, state_file = _write_fixture(stable_root, SCENARIOS[0]) + import_fixture_authority(registry_path, GOAL_ID) command = _mode_variant_commands( project=project, runtime=runtime, @@ -894,8 +899,13 @@ def test_turn_envelope_references_selected_todo_without_duplicate_context( assert payload["action"]["selected_todo"]["text_ref"] == "action.recommended_action" work_context = payload["work_context"] assert work_context["selected_todo_ref"] == "selected_todo" - assert work_context["selected_todo_authority"].startswith("markdown_active_state@sha256:") - assert "sources" not in work_context + source = next(row for row in work_context["sources"] if row["source"] == "selected_todo") + authority = source["content"]["authority_read"] + assert authority["source_authority"] == "file_v0" + assert authority["provider_revision"].startswith("file:") + assert authority["decision_read_from_provider"] is True + assert authority["legacy_fallback_used"] is False + assert "text" not in source["content"]["todo"] @pytest.mark.parametrize("surface_id", ["quota_should_run", "quota_should_run_turn_envelope"]) @@ -908,6 +918,7 @@ def test_compact_selected_todo_markdown_preserves_work_context_instruction( stable_root, SCENARIOS[0], ) + import_fixture_authority(registry_path, GOAL_ID) if surface_id == "quota_should_run": json_command = _surface_commands( project=project, @@ -979,6 +990,7 @@ def test_quota_should_run_no_format_uses_machine_contract_json( stable_root, SCENARIOS[0], ) + import_fixture_authority(registry_path, GOAL_ID) explicit_json_command = _surface_commands( project=project, runtime=runtime, @@ -1015,6 +1027,7 @@ def test_quota_cli_keeps_full_agent_todo_diagnostics_on_explicit_cold_path( stable_root, SCENARIOS[1], ) + import_fixture_authority(registry_path, GOAL_ID) default_command = _surface_commands( project=project, runtime=runtime, @@ -1056,12 +1069,15 @@ def test_quota_cli_keeps_full_agent_todo_diagnostics_on_explicit_cold_path( ) assert "backlog_items" not in default_summary assert detail_summary["backlog_items"] - assert "todo_summary_projection" not in detail_payload + assert detail_payload["todo_summary_projection"]["compacted_roles"] == ["user"] + assert detail_payload["todo_summary_projection"]["detail_ref"] == ( + "quota should-run --include-detail user-todos" + ) for key in ("interaction_contract", "scheduler_hint", "selected_todo"): assert default_payload.get(key) == detail_payload.get(key) -def test_malformed_todo_state_fails_closed_without_dropping_bounded_detail( +def test_unmigrated_malformed_todo_state_stays_bounded_and_refuses_ordinary_reads( tmp_path: Path, ) -> None: with _stable_budget_fixture_root(tmp_path / "malformed-todo-state") as stable_root: @@ -1074,6 +1090,7 @@ def test_malformed_todo_state_fails_closed_without_dropping_bounded_detail( state_text.replace("status=open", "status=stuck", 1), encoding="utf-8", ) + before = state_file.read_bytes() commands = _surface_commands( project=project, runtime=runtime, @@ -1088,73 +1105,21 @@ def test_malformed_todo_state_fails_closed_without_dropping_bounded_detail( state_file=state_file, output_format="json", )["quota_should_run_todo_summary_detail"] - - status_exit_code, status_text = _invoke_cli(commands["status"]) - default_exit_code, default_text = _invoke_cli(commands["quota_should_run"]) - detail_exit_code, detail_text = _invoke_cli(detail_command) - - assert status_exit_code == 1, status_text - status_measurement = measure_cli_output(status_text, output_format="json") - assert_cli_output_baseline( - CLI_OUTPUT_BUDGET_BY_ID["status"], - scenario="crowded", - output_format="json", - text=status_text, - measurement=status_measurement, - ) - status_payload = json.loads(status_text) - error_codes = { - row.get("code") - for row in status_payload["contract"]["error_diagnostics"] - if isinstance(row, dict) - } - assert "todo_status_invalid" in error_codes - - assert default_exit_code == 1, default_text - default_measurement = measure_cli_output(default_text, output_format="json") - assert_cli_output_baseline( - CLI_OUTPUT_BUDGET_BY_ID["quota_should_run"], - scenario="crowded", - output_format="json", - text=default_text, - measurement=default_measurement, - ) - default_payload = json.loads(default_text) - assert default_payload["status_health_ok"] is False - assert default_payload["should_run"] is False - assert default_payload["effective_action"] == "quota_skip" - default_summary = default_payload["agent_todo_summary"] - omitted_backlog = default_summary["payload_compaction"]["omitted_lanes"][ - "backlog_items" - ] - assert omitted_backlog > 0 - assert "backlog_items" not in default_summary - assert default_payload["todo_summary_projection"]["detail_ref"] == ( - "quota should-run --include-detail agent-todos" - ) - - assert detail_exit_code == 1, detail_text - detail_measurement = measure_cli_output(detail_text, output_format="json") - assert_cli_output_mode_variant( - CLI_OUTPUT_MODE_VARIANT_BY_ID["quota_should_run_todo_summary_detail"], - output_format="json", - text=detail_text, - measurement=detail_measurement, - ) - detail_payload = json.loads(detail_text) - assert detail_payload["status_health_ok"] is False - assert detail_payload["should_run"] is False - assert detail_payload["effective_action"] == "quota_skip" - detail_summary = detail_payload["agent_todo_summary"] - backlog_budget = detail_summary["payload_compaction"]["compacted_lanes"][ - "backlog_items" - ] - assert backlog_budget["total"] > backlog_budget["shown"] > 0 - assert len(detail_summary["backlog_items"]) == backlog_budget["shown"] - assert detail_summary["backlog_items"][0]["todo_id"] == "todo_fixture_000" - assert str(project) not in json.dumps(detail_summary["backlog_items"]) - for key in ("decision", "should_run", "effective_action", "selected_todo"): - assert default_payload.get(key) == detail_payload.get(key) + for command in [commands["status"], commands["quota_should_run"], detail_command]: + exit_code, text = _invoke_cli(command) + assert exit_code == 1, text + payload = json.loads(text) + assert payload["error_code"] == "canonical_authority_required" + authority = payload.get("authority_read", payload) + assert authority["write_admitted"] is False + assert authority["decision_read_from_provider"] is False + assert authority["legacy_fallback_used"] is False + assert authority["recovery"] == { + "action": "migrate_goal_authority", "backup_required": True, + } + assert "todo_fixture_000" not in text + assert measure_cli_output(text, output_format="json")["chars"] < 2_000 + assert state_file.read_bytes() == before def test_quota_cli_keeps_complete_vision_rules_with_reachable_duplicate_refs( @@ -1166,6 +1131,7 @@ def test_quota_cli_keeps_complete_vision_rules_with_reachable_duplicate_refs( SCENARIOS[1], include_agent_vision=True, ) + import_fixture_authority(registry_path, GOAL_ID) default_command = _surface_commands( project=project, runtime=runtime, @@ -1273,6 +1239,7 @@ def test_crowded_turn_plan_budget_preserves_executable_vision_authoring( SCENARIOS[1], include_agent_vision=include_agent_vision, ) + import_fixture_authority(registry_path, GOAL_ID) command = _surface_commands( project=project, runtime=runtime, @@ -1305,6 +1272,7 @@ def test_crowded_quota_preserves_long_chain_decision_clauses( project, runtime, registry_path, state_file = _write_fixture( stable_root, SCENARIOS[1], include_agent_vision=include_agent_vision, ) + import_fixture_authority(registry_path, GOAL_ID) command = _surface_commands( project=project, runtime=runtime, registry_path=registry_path, state_file=state_file, output_format="json", @@ -1338,6 +1306,7 @@ def test_quota_cli_keeps_full_user_todo_diagnostics_on_explicit_cold_path( SCENARIOS[2], ) _write_user_todo_fixture(state_file) + import_fixture_authority(registry_path, GOAL_ID) default_command = _surface_commands( project=project, runtime=runtime, @@ -1392,6 +1361,7 @@ def test_quota_cli_keeps_goal_boundary_authority_on_explicit_cold_path( stable_root, SCENARIOS[1], ) + import_fixture_authority(registry_path, GOAL_ID) _write_checkpointed_boundary_authority(registry_path) default_command = _surface_commands( project=project, @@ -1457,6 +1427,7 @@ def test_quota_cli_deprecated_scheduler_detail_flag_matches_canonical_selector( SCENARIOS[2], ) _write_user_todo_fixture(state_file) + import_fixture_authority(registry_path, GOAL_ID) _write_checkpointed_boundary_authority(registry_path) default_command = _surface_commands( project=project, @@ -1494,6 +1465,7 @@ def test_quota_cli_all_detail_matches_repeated_canonical_selectors( SCENARIOS[2], ) _write_user_todo_fixture(state_file) + import_fixture_authority(registry_path, GOAL_ID) _write_checkpointed_boundary_authority(registry_path) default_command = _surface_commands( project=project, @@ -1529,6 +1501,7 @@ def test_agent_scoped_status_keeps_whole_goal_todo_index_on_cold_path( stable_root, SCENARIOS[1], ) + import_fixture_authority(registry_path, GOAL_ID) command = _surface_commands( project=project, runtime=runtime, @@ -1574,6 +1547,7 @@ def test_status_and_quota_json_ignore_compatibility_reexport_bindings( stable_root, SCENARIOS[0], ) + import_fixture_authority(registry_path, GOAL_ID) commands = _surface_commands( project=project, runtime=runtime, @@ -1688,6 +1662,7 @@ def test_explicit_compact_and_detail_modes_are_characterized(tmp_path: Path) -> def _assert_mode_variant_budgets(root: Path, *, only: str | None = None) -> None: project, runtime, registry_path, state_file = _write_fixture(root, SCENARIOS[0]) + import_fixture_authority(registry_path, GOAL_ID) for output_format in ("json", "markdown"): commands = _mode_variant_commands( project=project, @@ -1718,6 +1693,7 @@ def test_thin_json_budget_retains_full_supported_scope(tmp_path: Path) -> None: # to the supported boundary. JSON/body budgets must fit complete guidance. with _stable_budget_fixture_root(tmp_path) as root: project, runtime, registry_path, state_file = _write_fixture(root, SCENARIOS[0]) + import_fixture_authority(registry_path, GOAL_ID) command = _surface_commands( project=project, runtime=runtime, registry_path=registry_path, state_file=state_file, output_format="json", @@ -1766,6 +1742,8 @@ def test_brief_long_path_budget_retains_semantics_across_parent_depths( ) -> None: from types import SimpleNamespace + from managed_effect_runtime_fixture import stop_disposable_effect_runtimes + # Use the native namespace on every host, without changing pathlib or the # product's platform checks. Keep raw output measurements for enforcement. monkeypatch.setitem(globals(), "os", SimpleNamespace(name="nt", environ=os.environ)) @@ -1806,8 +1784,11 @@ def capture_output(command): monkeypatch.setitem(globals(), "_assert_mode_variant_budgets", capture_root) monkeypatch.setitem(globals(), "_invoke_cli", capture_output) - with tempfile.TemporaryDirectory(prefix="loopx-depth-") as directory: + with tempfile.TemporaryDirectory(prefix="loopx-depth-") as directory, contextlib.ExitStack() as cleanup: base = Path(directory).resolve() + # Each depth can start a real runtime in that temporary namespace. + # Retire only these owned processes before Windows removes their cwd. + cleanup.callback(stop_disposable_effect_runtimes, base) # Do not silently climb to an unqualified writable ancestor to fit. if len(str(base.joinpath(*("d" for _ in range(12))) / "loopx-brief-")) + 8 > 128: pytest.skip("Native temp namespace cannot fit the qualified depth-12 layout") @@ -1867,6 +1848,7 @@ def test_todo_list_explicit_limit_stays_bounded_and_default_path_unchanged( stable_root, SCENARIOS[1], ) + import_fixture_authority(registry_path, GOAL_ID) limited_command = _mode_variant_commands( project=project, runtime=runtime, @@ -1947,6 +1929,7 @@ def test_todo_list_thin_reduces_crowded_output_and_keeps_default_unchanged( stable_root, SCENARIOS[1], ) + import_fixture_authority(registry_path, GOAL_ID) thin_command = _mode_variant_commands( project=project, runtime=runtime, @@ -2008,6 +1991,7 @@ def test_todo_list_explicit_limit_bounds_monitor_and_blocker_lanes( SCENARIOS[1], ) _write_todo_list_limit_stress_fixture(state_file) + import_fixture_authority(registry_path, GOAL_ID) command = _mode_variant_commands( project=project, runtime=runtime, @@ -2054,6 +2038,7 @@ def test_turn_envelope_cli_preserves_codex_app_scheduler_binding( tmp_path, SCENARIOS[0], ) + import_fixture_authority(registry_path, GOAL_ID) command = _mode_variant_commands( project=project, runtime=runtime, @@ -2085,6 +2070,7 @@ def test_quota_should_run_cli_actions_keep_explicit_runtime_root( tmp_path, SCENARIOS[0], ) + import_fixture_authority(registry_path, GOAL_ID) exit_code, text = _invoke_cli( [ @@ -2152,6 +2138,7 @@ def test_first_class_runtime_profiles_fit_thin_prompt_budget_and_cli_round_trip( root, SCENARIOS[0], ) + import_fixture_authority(registry_path, GOAL_ID) prompt = build_heartbeat_prompt( goal_id=GOAL_ID, active_state=state_file, diff --git a/tests/control_plane/test_cold_source_import_cli.py b/tests/control_plane/test_cold_source_import_cli.py index 99f96bf957..5071d6bbd7 100644 --- a/tests/control_plane/test_cold_source_import_cli.py +++ b/tests/control_plane/test_cold_source_import_cli.py @@ -250,7 +250,7 @@ def test_unconfigured_shadow_inspection_matches_full_cli_without_import( ("authority-shadow", "status", "local_authority_shadow_adapter", True), ("authority-shadow", "status", "local_authority_shadow_readback", False), ("authority-shadow", "drain", "local_authority_shadow_adapter", False), - ("todo", "list", "local_authority_shadow_readback", True), + ("todo", "list", "local_authority_shadow_readback", False), ]) def test_explicit_shadow_action_uses_only_its_required_modules_without_writing( tmp_path, monkeypatch, command, action, missing, expected_ok, isolated_todo_distribution, # noqa: F811 -- shared fixture @@ -280,7 +280,9 @@ def test_explicit_shadow_action_uses_only_its_required_modules_without_writing( assert child.returncode == (0 if expected_ok else 1) result = json.loads(child.stdout) assert result["ok"] is expected_ok - if not expected_ok: + if command == "todo": + assert result["error_code"] == "canonical_authority_required" + elif not expected_ok: assert missing in result["error"] after = {str(path.relative_to(tmp_path)): path.read_bytes() for root in (registry.parent, state.parent, runtime) diff --git a/tests/control_plane/test_goal_channel_hard_lease_visibility.py b/tests/control_plane/test_goal_channel_hard_lease_visibility.py index 780b56180f..9f3bd0681e 100644 --- a/tests/control_plane/test_goal_channel_hard_lease_visibility.py +++ b/tests/control_plane/test_goal_channel_hard_lease_visibility.py @@ -72,6 +72,22 @@ def _write_active_lease( return lease +def _seed_observation(runtime: Path, *, claimed_by: str, leases: list[dict[str, Any]]) -> None: + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from loopx.control_plane.todos.todo_summary import canonical_todo_read_record, structured_todo_item + + state = runtime / "source.md" + state.write_text("# Goal\n\n## Agent Todo\n") + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[ + canonical_todo_read_record(structured_todo_item({ + "todo_id": TODO_ID, "status": "open", "claimed_by": claimed_by, + "text": "Observe canonical ownership", "task_class": "advancement_task", + }, role="agent", source_section="Agent Todo")), + ]) + projection["leases"] = leases + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state) + + def _active_leases(projection: dict[str, Any]) -> list[dict[str, Any]]: leases = projection.get("active_leases") assert isinstance(leases, list) @@ -86,6 +102,7 @@ def _entries_with_status( def test_divergent_hard_lease_is_surfaced_with_conflict_reason(tmp_path: Path) -> None: lease = _write_active_lease(tmp_path, owner="agent-a") + _seed_observation(tmp_path, claimed_by="agent-b", leases=[lease]) projection = build_goal_channel_projection( goal_id=GOAL_ID, status_item=_status_item(claimed_by="agent-b"), @@ -113,6 +130,7 @@ def test_divergent_hard_lease_is_surfaced_with_conflict_reason(tmp_path: Path) - def test_matching_hard_lease_is_surfaced_without_conflict(tmp_path: Path) -> None: lease = _write_active_lease(tmp_path, owner="agent-a") + _seed_observation(tmp_path, claimed_by="agent-a", leases=[lease]) projection = build_goal_channel_projection( goal_id=GOAL_ID, status_item=_status_item(claimed_by="agent-a"), @@ -133,6 +151,7 @@ def test_matching_hard_lease_is_surfaced_without_conflict(tmp_path: Path) -> Non def test_soft_only_goal_preserves_previous_projection_shape(tmp_path: Path) -> None: + _seed_observation(tmp_path, claimed_by="agent-b", leases=[]) projection = build_goal_channel_projection( goal_id=GOAL_ID, status_item=_status_item(claimed_by="agent-b"), @@ -144,15 +163,14 @@ def test_soft_only_goal_preserves_previous_projection_shape(tmp_path: Path) -> N ] -def test_missing_runtime_root_preserves_previous_projection_shape() -> None: +def test_missing_runtime_root_never_promotes_display_claims_to_ownership() -> None: projection = build_goal_channel_projection( goal_id=GOAL_ID, status_item=_status_item(claimed_by="agent-b"), ) - assert _active_leases(projection) == [ - {"todo_id": TODO_ID, "owner_agent": "agent-b", "status": "soft_claim"} - ] + assert _active_leases(projection) == [] + assert projection["coordination_observation"]["status"] == "unavailable" def test_expired_lease_is_not_surfaced(tmp_path: Path) -> None: @@ -176,6 +194,8 @@ def test_expired_lease_is_not_surfaced(tmp_path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(lease), encoding="utf-8") + _seed_observation(tmp_path, claimed_by="agent-b", leases=[lease]) + projection = build_goal_channel_projection( goal_id=GOAL_ID, status_item=_status_item(claimed_by="agent-b"), @@ -189,6 +209,10 @@ def test_corrupt_lease_degrades_to_typed_note(tmp_path: Path) -> None: lease_dir = task_lease_dir(runtime_root=tmp_path, goal_id=GOAL_ID) lease_dir.mkdir(parents=True, exist_ok=True) (lease_dir / f"{TODO_ID}.json").write_text("{not json", encoding="utf-8") + _seed_observation(tmp_path, claimed_by="agent-b", leases=[{ + "todo_id": TODO_ID, "goal_id": GOAL_ID, "schema_version": "unknown", + "status": "active", "expires_at": "2099-01-01T00:00:00Z", + }]) projection = build_goal_channel_projection( goal_id=GOAL_ID, @@ -208,7 +232,8 @@ def test_corrupt_lease_degrades_to_typed_note(tmp_path: Path) -> None: def test_attach_goal_channel_projection_forwards_runtime_root(tmp_path: Path) -> None: - _write_active_lease(tmp_path, owner="agent-a") + lease = _write_active_lease(tmp_path, owner="agent-a") + _seed_observation(tmp_path, claimed_by="agent-b", leases=[lease]) item = _status_item(claimed_by="agent-b") attach_goal_channel_projection( item, @@ -225,17 +250,18 @@ def test_attach_goal_channel_projection_forwards_runtime_root(tmp_path: Path) -> assert hard[0]["reason"] == "owner_conflicts_with_claim" -def test_lease_released_between_glob_and_read_is_skipped( +def test_canonical_observation_never_opens_local_lease_files( tmp_path: Path, monkeypatch: Any ) -> None: - """A lease unlinked after the directory listing is not labeled corrupt.""" + """Canonical ownership has no file-listing race or local read fallback.""" from loopx.control_plane.work_items import task_lease as task_lease_module - _write_active_lease(tmp_path, owner="agent-a") + lease = _write_active_lease(tmp_path, owner="agent-a") + _seed_observation(tmp_path, claimed_by="agent-b", leases=[lease]) def _raise_file_not_found(path: Path) -> dict[str, Any] | None: - raise FileNotFoundError(path) + pytest.fail("canonical observation opened a legacy lease file") monkeypatch.setattr(task_lease_module, "read_lease", _raise_file_not_found) projection = build_goal_channel_projection( @@ -244,9 +270,9 @@ def _raise_file_not_found(path: Path) -> dict[str, Any] | None: runtime_root=tmp_path, ) - assert _entries_with_status(projection, "hard_lease") == [] + assert len(_entries_with_status(projection, "hard_lease")) == 1 assert _entries_with_status(projection, "hard_lease_unreadable") == [] - # soft claim entry is unaffected by the vanished lease file + # The claim and lease come from the same provider snapshot. assert _entries_with_status(projection, "soft_claim") @@ -255,7 +281,8 @@ def test_html_renderer_shows_hard_lease_conflict_fields(tmp_path: Path) -> None: render_goal_channel_projection_html, ) - _write_active_lease(tmp_path, owner="agent-a") + lease = _write_active_lease(tmp_path, owner="agent-a") + _seed_observation(tmp_path, claimed_by="agent-b", leases=[lease]) projection = build_goal_channel_projection( goal_id=GOAL_ID, status_item=_status_item(claimed_by="agent-b"), diff --git a/tests/control_plane/test_next_action_writeback.py b/tests/control_plane/test_next_action_writeback.py index 8a7df40cd9..bf189c3805 100644 --- a/tests/control_plane/test_next_action_writeback.py +++ b/tests/control_plane/test_next_action_writeback.py @@ -7,6 +7,10 @@ import pytest import loopx.state_refresh as refresh +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority +from loopx.control_plane.testing.canary_harness import run_json_cli +from loopx.control_plane.todos.provider_projection import project_current_canonical_todos +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.work_items.recommendation_source_io import RecommendationWritebackRejected from loopx.status import collect_status from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted @@ -42,6 +46,13 @@ def fixture(tmp_path, agents=("agent-a",)): "state_file": state.name, "coordination": {"registered_agents": list(agents)}} runtime = tmp_path / "runtime" registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [goal]})) + from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source + items, _, _ = parse_todo_source(STATE) + projection = build_todo_runtime_shadow_projection(goal_id=goal["id"], + todos=[{"schema_version": "todo_item_v0", **item} for item in items["agent"]], + leases=[], handoff_mode="soft_claim") + initialize_canonical_authority(runtime, goal["id"], projection, state_path=state) + project_current_canonical_todos(registry_path=registry, runtime_root=runtime, goal_id=goal["id"]) return registry, state, runtime, goal @@ -67,6 +78,7 @@ def routes(registry, runtime, root): @pytest.mark.parametrize("agents", [("agent-a",), ("agent-a", "agent-b")]) def test_personal_step_reuses_resolution_and_preserves_state_and_ownership(tmp_path, agents): registry, state, runtime, _ = fixture(tmp_path, agents) + original_state = state.read_text() before, selected = routes(registry, runtime, tmp_path) result = write(registry, runtime, next_action_basis=selected["agent-a"]["next_action_basis"]) receipt = result["recommended_action_resolution"] @@ -74,7 +86,7 @@ def test_personal_step_reuses_resolution_and_preserves_state_and_ownership(tmp_p assert receipt["todo_id"] == "todo_parser" assert result["progress_scope"] == "agent_lane" and result["agent_id"] == "agent-a" assert "active_state_next_action_update" not in result - assert state.read_text() == STATE + assert state.read_text() == original_state run = json.loads((runtime / "goals/next-action-goal/runs/index.jsonl").read_text()) assert run["recommended_action_resolution"] == receipt _, after = routes(registry, runtime, tmp_path) @@ -85,6 +97,7 @@ def test_personal_step_reuses_resolution_and_preserves_state_and_ownership(tmp_p def test_multi_agent_steps_are_independent_and_report_scope_does_not_grant_authority(tmp_path): registry, state, runtime, _ = fixture(tmp_path, ("agent-a", "agent-b")) + original_state = state.read_text() _, before = routes(registry, runtime, tmp_path) write(registry, runtime, next_action_basis=before["agent-a"]["next_action_basis"]) _, after_a = routes(registry, runtime, tmp_path) @@ -94,24 +107,26 @@ def test_multi_agent_steps_are_independent_and_report_scope_does_not_grant_autho _, after_b = routes(registry, runtime, tmp_path) assert after_b["agent-a"]["next_step"] == after_a["agent-a"]["next_step"] assert after_b["agent-b"]["next_step"] == "Evaluate the incumbent." - assert state.read_text() == STATE + assert state.read_text() == original_state @pytest.mark.parametrize("agents,actor", [([], "agent-a"), (["agent-b"], "agent-a"), (["agent-a"], None)]) def test_unknown_or_unattributed_steps_do_not_append(tmp_path, agents, actor): registry, state, runtime, _ = fixture(tmp_path, agents) + original_state = state.read_text() with pytest.raises(ValueError): write(registry, runtime, agent_id=actor) - assert state.read_text() == STATE + assert state.read_text() == original_state assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() def test_dry_run_checks_binding_without_writing_or_appending(tmp_path): registry, state, runtime, _ = fixture(tmp_path) + original_state = state.read_text() result = write(registry, runtime, dry_run=True) assert result["recommended_action_resolution"]["todo_id"] == "todo_parser" assert result["appended"] is False - assert state.read_text() == STATE + assert state.read_text() == original_state assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() @@ -129,6 +144,7 @@ def test_explicit_matching_step_keeps_task_binding_and_is_not_replan_evidence(tm @pytest.mark.parametrize("length", [1200, 1201]) def test_cli_next_step_budget_checks_trimmed_text_before_append(tmp_path, length): registry, state, runtime, goal = fixture(tmp_path) + original_state = state.read_text() result = subprocess.run( [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry), "--runtime-root", str(runtime), "refresh-state", "--goal-id", goal["id"], @@ -146,7 +162,7 @@ def test_cli_next_step_budget_checks_trimmed_text_before_append(tmp_path, length assert result.returncode == 1, payload assert payload["error"] == "next_action exceeds 1200 characters" assert not index.exists() - assert state.read_text() == STATE + assert state.read_text() == original_state def test_same_actor_concurrent_cli_writers_cannot_commit_one_old_basis_twice(tmp_path): @@ -178,7 +194,11 @@ def concurrent_change(**kwargs): data["goals"][0]["coordination"]["registered_agents"].append("offline-peer") registry.write_text(json.dumps(data)) elif change == "task": - state.write_text(STATE.replace("Inspect the parser.", "Inspect the changed parser contract.")) + run_json_cli("todo", "update", "--goal-id", "next-action-goal", + "--todo-id", "todo_parser", "--agent-id", "agent-a", + "--text", "Inspect the changed parser contract.", + "--update-operation-id", "fixture-task-race", + registry_path=registry, runtime_root=runtime) else: state.write_text("# A changed acceptance basis\n" + STATE) return result @@ -191,7 +211,7 @@ def concurrent_change(**kwargs): def test_step_does_not_destroy_binding_or_block_completion_projection(tmp_path): registry, state, runtime, _ = fixture(tmp_path, ("agent-a", "agent-b")) - bound = STATE.replace("- Preserve the current shared route.", + bound = state.read_text().replace("- Preserve the current shared route.", "- [P1] Inspect the parser.\n") state.write_text(bound) write(registry, runtime) @@ -199,7 +219,9 @@ def test_step_does_not_destroy_binding_or_block_completion_projection(tmp_path): completed = bound.replace("todo_parser status=open", "todo_parser status=done").splitlines() assert settle_completed_todo_next_action(completed, completed_todo_id="todo_parser") assert "todo_id=todo_evaluate" in "\n".join(completed) - state.write_text("\n".join(completed)) + run_json_cli("todo", "complete", "--goal-id", "next-action-goal", + "--todo-id", "todo_parser", "--agent-id", "agent-a", "--evidence", "fixture completion", + registry_path=registry, runtime_root=runtime) _, after = routes(registry, runtime, tmp_path) assert "agent-a" not in after assert "next_step" not in after["agent-b"] @@ -208,10 +230,16 @@ def test_step_does_not_destroy_binding_or_block_completion_projection(tmp_path): def test_task_change_invalidates_step_and_no_eligible_task_cannot_accept_one(tmp_path): registry, state, runtime, _ = fixture(tmp_path) write(registry, runtime) - state.write_text(STATE.replace("Inspect the parser.", "Inspect another parser interface.")) + run_json_cli("todo", "update", "--goal-id", "next-action-goal", + "--todo-id", "todo_parser", "--agent-id", "agent-a", + "--text", "Inspect another parser interface.", "--update-operation-id", "fixture-task-change", + registry_path=registry, runtime_root=runtime) _, changed = routes(registry, runtime, tmp_path) assert "next_step" not in changed["agent-a"] - state.write_text(STATE.replace("todo_parser status=open", "todo_parser status=blocked")) + run_json_cli("todo", "update", "--goal-id", "next-action-goal", + "--todo-id", "todo_parser", "--agent-id", "agent-a", "--status", "blocked", + "--reason", "Wait for a new artifact.", "--update-operation-id", "fixture-task-blocked", + registry_path=registry, runtime_root=runtime) with pytest.raises(RecommendationWritebackRejected, match="No selected eligible"): write(registry, runtime) @@ -235,11 +263,12 @@ def shared_fixture(tmp_path): def test_shared_reads_and_writes_use_source_roster_tasks_and_route(tmp_path): registry, mirror, state, runtime, _ = shared_fixture(tmp_path) + original_state = state.read_text() _, selected = routes(mirror, runtime, tmp_path) assert set(selected) == {"agent-a", "agent-b"} result = write(mirror, runtime, next_action_basis=selected["agent-a"]["next_action_basis"]) assert result["recommended_action_resolution"]["todo_id"] == "todo_parser" - assert state.read_text() == STATE + assert state.read_text() == original_state assert "stale mirror route" in (tmp_path / "stale-state.md").read_text() @@ -296,10 +325,11 @@ def test_canonical_step_uses_tasks_without_a_markdown_display(canonical_projecti def test_unavailable_canonical_reader_never_uses_stale_markdown(tmp_path, monkeypatch): from loopx.control_plane.work_items import refresh_recommendation registry, state, runtime, _ = fixture(tmp_path) + original_state = state.read_text() def unavailable(**kwargs): raise RuntimeError("canonical source is unavailable") monkeypatch.setattr(refresh_recommendation, "read_canonical_todos_if_promoted", unavailable) with pytest.raises(RuntimeError, match="canonical source is unavailable"): write(registry, runtime) - assert state.read_text() == STATE + assert state.read_text() == original_state assert not (runtime / "goals/next-action-goal/runs/index.jsonl").exists() diff --git a/tests/control_plane/test_selected_work_requirements.py b/tests/control_plane/test_selected_work_requirements.py index c3025b5c29..8935d2eb4b 100644 --- a/tests/control_plane/test_selected_work_requirements.py +++ b/tests/control_plane/test_selected_work_requirements.py @@ -12,6 +12,7 @@ import pytest from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.local_authority import CanonicalAuthorityRequired from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.testing.canary_harness import run_json_cli_result, write_fixture_registry from loopx.control_plane.work_items.context_readback import _source_content @@ -25,7 +26,7 @@ REPO = Path(__file__).resolve().parents[2] -def test_legacy_selected_todo_requires_same_goal_state_revision(tmp_path: Path) -> None: +def test_selected_todo_refuses_unmigrated_source(tmp_path: Path) -> None: state = tmp_path / "state.md" state.write_text( "---\nstatus: active\n---\n# Goal\n## Agent Todo\n" @@ -33,16 +34,17 @@ def test_legacy_selected_todo_requires_same_goal_state_revision(tmp_path: Path) " \n" ) - with pytest.raises(ValueError, match="changed after the Goal state read"): + original = state.read_bytes() + with pytest.raises(CanonicalAuthorityRequired) as refused: _source_content( {"source": "selected_todo"}, registry_path=tmp_path / "registry.json", runtime_root=tmp_path / "runtime", goal_id="goal-current", todo_id="todo_current", - state_file=state, - expected_state_revision="sha256:stale", ) + assert refused.value.code == "canonical_authority_required" + assert state.read_bytes() == original @pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) @@ -90,6 +92,14 @@ def guard(*extra, expected_exit=0): assert code == expected_exit, json.dumps(packet, indent=2) return packet + if provider == "legacy": + refused = guard(expected_exit=1) + assert refused["error_code"] == "canonical_authority_required" + assert refused["decision_read_from_provider"] is False + assert refused["should_run"] is False + assert state.read_text() == state_text + return + packet = guard() channel = packet["interaction_contract"]["agent_channel"] users = channel["work_context"]["user_todos"]["todos"] @@ -103,18 +113,17 @@ def guard(*extra, expected_exit=0): envelope = guard("--turn-envelope") assert envelope["work_context"]["user_todos"] == channel["work_context"]["user_todos"] assert state.read_text() == state_text - if provider != "legacy": - # Canonical tasks still exist: the full Goal source must fail closed, - # rather than silently substituting a Todo summary for missing intent. - state.unlink() - failed = guard(expected_exit=1) - assert failed["ok"] is False and failed["status_health_ok"] is False - failed_channel = failed["interaction_contract"]["agent_channel"] - assert failed_channel["delivery_allowed"] is False - assert failed_channel["work_context"]["complete"] is False - assert any(read["source"] == "goal_state" for read in failed_channel["required_reads"]) - state.write_text(state_text) - assert guard()["interaction_contract"]["agent_channel"]["work_context"]["complete"] is True + # Canonical tasks still exist: the full Goal source must fail closed, + # rather than silently substituting a Todo summary for missing intent. + state.unlink() + failed = guard(expected_exit=1) + assert failed["ok"] is False and failed["status_health_ok"] is False + failed_channel = failed["interaction_contract"]["agent_channel"] + assert failed_channel["delivery_allowed"] is False + assert failed_channel["work_context"]["complete"] is False + assert any(read["source"] == "goal_state" for read in failed_channel["required_reads"]) + state.write_text(state_text) + assert guard()["interaction_contract"]["agent_channel"]["work_context"]["complete"] is True @pytest.mark.parametrize("provider", ["file", "sqlite"]) diff --git a/tests/control_plane/test_shadow_writer_boundaries.py b/tests/control_plane/test_shadow_writer_boundaries.py index 34503c6e8a..56ac9ba25b 100644 --- a/tests/control_plane/test_shadow_writer_boundaries.py +++ b/tests/control_plane/test_shadow_writer_boundaries.py @@ -388,6 +388,11 @@ def test_migration_refuses_overwriting_a_managed_target_before_copy(tmp_path: Pa def test_public_refresh_step_does_not_write_owned_paragraph(tmp_path: Path) -> None: registry, state, root = fixture(tmp_path) seed_todo(state, "Inspect the bounded result.") + from loopx.control_plane.testing.canary_harness import import_fixture_authority + import_fixture_authority(registry, GOAL) + revision = cli(registry, "todo", "list", "--goal-id", GOAL)["authority_read"]["provider_revision"] + cli(registry, "todo", "project-markdown", "--goal-id", GOAL, + "--provider-revision", revision, "--execute") before = state.read_bytes() result = cli(registry, "refresh-state", "--goal-id", GOAL, "--agent-id", "agent-a", "--next-action", "Compare the current evidence.", "--no-global-sync") diff --git a/tests/control_plane/test_shared_goal_alignment.py b/tests/control_plane/test_shared_goal_alignment.py index ccc6b988ad..f4233ae1a4 100644 --- a/tests/control_plane/test_shared_goal_alignment.py +++ b/tests/control_plane/test_shared_goal_alignment.py @@ -1,4 +1,4 @@ -"""Stage 1 read-only shared goal alignment projection tests. +"""Read-only shared Goal alignment over canonical provider facts. Every fixture Todo metadata line must only use tokens that exist in ``_TODO_METADATA_FIELD_SCHEMA`` (``todos/contract.py``): the parser silently @@ -23,8 +23,12 @@ project_shared_goal_alignment, ) from loopx.control_plane.todos.active_state_todo_parser import ( - parse_active_state_todos, + parse_active_state_todos, parse_todo_source, ) +from canonical_authority_fixture import initialize_canonical_authority +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.coordination.local_authority import CanonicalAuthorityRequired +from loopx.control_plane.todos.todo_summary import canonical_todo_read_record, structured_todo_item GOAL_ID = "goal-stage1" AGENTS = ("agent-a", "agent-b") @@ -43,7 +47,7 @@ def test_excluded_unclaimed_work_is_not_offered_to_the_agent(tmp_path): @pytest.mark.parametrize("lease_bytes", [b"{broken", b"\xff"], ids=["invalid-json", "invalid-utf8"]) -def test_corrupt_legacy_lease_only_blocks_a_selected_claim(tmp_path, lease_bytes): +def test_corrupt_legacy_lease_never_replaces_canonical_claims(tmp_path, lease_bytes): from loopx.control_plane.work_items.task_lease import task_lease_path specs = _default_todo_specs() @@ -54,15 +58,17 @@ def test_corrupt_legacy_lease_only_blocks_a_selected_claim(tmp_path, lease_bytes path = task_lease_path(runtime_root=fixture["runtime"], goal_id=GOAL_ID, todo_id=todo_id) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(lease_bytes) - if todo_id == "todo_blocked": - result = project_shared_goal_alignment(goal_id=GOAL_ID, agent_id="agent-a", - project=fixture["project"], registry_path=fixture["registry"], runtime_root=fixture["runtime"]) - assert result["frontier_counts"]["current_agent_claimed_advancement_count"] == 1 - assert result["frontier_counts"]["other_agent_claimed_advancement_count"] == 1 - else: - with pytest.raises(ValueError, match="cannot read selected claim lease"): - project_shared_goal_alignment(goal_id=GOAL_ID, agent_id="agent-a", - project=fixture["project"], registry_path=fixture["registry"], runtime_root=fixture["runtime"]) + result = project_shared_goal_alignment(goal_id=GOAL_ID, agent_id="agent-a", + project=fixture["project"], registry_path=fixture["registry"], runtime_root=fixture["runtime"]) + assert result["frontier_counts"]["current_agent_claimed_advancement_count"] == 1 + assert result["frontier_counts"]["other_agent_claimed_advancement_count"] == 1 + + +def test_unmigrated_alignment_refuses_old_work_and_lease_files(tmp_path): + fixture = _write_fixture(tmp_path, todo_specs=_default_todo_specs(), canonical=False) + with pytest.raises(CanonicalAuthorityRequired): + project_shared_goal_alignment(goal_id=GOAL_ID, agent_id="agent-a", + project=fixture["project"], registry_path=fixture["registry"], runtime_root=fixture["runtime"]) @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -74,7 +80,7 @@ def test_promoted_alignment_does_not_use_the_display(tmp_path, monkeypatch, prov isolate_sqlite_runtime(tmp_path, monkeypatch) from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection - fixture = _write_fixture(tmp_path, todo_specs=_default_todo_specs()) + fixture = _write_fixture(tmp_path, todo_specs=_default_todo_specs(), canonical=False) record = {"schema_version": "todo_item_v0", "todo_id": "todo_canonical", "role": "agent", "status": "open", "done": False, "text": "Canonical work", "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo", "index": 1} @@ -123,6 +129,7 @@ def _write_fixture( leases: dict[str, dict[str, object]] | None = None, agents: tuple[str, ...] = AGENTS, goal_id: str = GOAL_ID, + canonical: bool = True, ) -> dict[str, Path]: project = root / "project" runtime = root / "runtime" @@ -181,6 +188,21 @@ def _write_fixture( runtime.mkdir(parents=True, exist_ok=True) _assert_fixture_todos_parsed(state_file, todo_specs) + if canonical: + active, archived, _ = parse_todo_source(state_file.read_text()) + projection = build_todo_runtime_shadow_projection( + goal_id=goal_id, todos=[canonical_todo_read_record(structured_todo_item( + item, role=item["role"], source_section=item["source_section"], + archive_state=item.get("archive_state", "active"), text_limit=None, + )) for item in [*active["user"], *active["agent"], *archived]], + ) + # Corruption cases deliberately seed malformed provider facts, rather + # than manufacturing a successful migration of invalid source data. + projection["leases"] = [ + {**lease, "goal_id": goal_id, "todo_id": todo_id} + for todo_id, lease in (leases or {}).items() + ] + initialize_canonical_authority(runtime, goal_id, projection, state_path=state_file) return { "project": project, "runtime": runtime, @@ -266,10 +288,10 @@ def test_projects_basis_binding_and_unclaimed_work( assert projection["agent_id"] == "agent-a" assert projection["read_only"] is True basis = projection["source_basis"] - assert basis["revision_basis"] == "markdown_active_state" + assert basis["revision_basis"] == "canonical_todo_snapshot" assert basis["state_event_basis_sequence"] == 0 assert basis["source_basis_digest"].startswith("sha256:") - assert basis["state_updated_at"] == "2026-09-01T00:00:00+00:00" + assert basis["state_updated_at"] is None frontier = projection["frontier_basis"] assert frontier["based_on_state_event_sequence"] is None assert frontier["basis_source"] == "unbound" @@ -333,7 +355,7 @@ def test_without_an_event_log_the_basis_is_unverifiable_not_behind( ) assert projection["source_basis"]["revision_basis"] == ( - "markdown_active_state" + "canonical_todo_snapshot" ) assert projection["source_basis"]["state_event_basis_sequence"] == 0 assert projection["frontier_basis"] == { @@ -377,7 +399,7 @@ def test_next_action_prose_never_changes_the_source_basis_digest( ) -def test_legacy_work_claim_changes_basis_without_a_timestamp_change(tmp_path): +def test_display_claim_changes_do_not_change_canonical_work_or_basis(tmp_path): paths = _write_fixture(tmp_path, todo_specs=_default_todo_specs()) arguments = dict(goal_id=GOAL_ID, agent_id="agent-a", project=paths["project"], registry_path=paths["registry"], runtime_root=paths["runtime"]) @@ -389,13 +411,13 @@ def test_legacy_work_claim_changes_basis_without_a_timestamp_change(tmp_path): encoding="utf-8") after = project_shared_goal_alignment(**arguments) assert before["unclaimed_eligible_work"] - assert after["unclaimed_eligible_work"] == [] + assert after["unclaimed_eligible_work"] == before["unclaimed_eligible_work"] assert before["source_basis"]["state_updated_at"] == after["source_basis"]["state_updated_at"] assert before["source_basis"]["state_event_basis_sequence"] == after["source_basis"]["state_event_basis_sequence"] == 0 - assert before["source_basis"]["source_basis_digest"] != after["source_basis"]["source_basis_digest"] + assert before["source_basis"]["source_basis_digest"] == after["source_basis"]["source_basis_digest"] -def test_legacy_basis_binds_lease_epoch_but_not_observation_time(tmp_path): +def test_legacy_lease_edits_do_not_change_canonical_basis(tmp_path): from loopx.control_plane.work_items.task_lease import task_lease_path lease = {"schema_version": "task_lease_v0", "status": "active", @@ -411,12 +433,12 @@ def test_legacy_basis_binds_lease_epoch_but_not_observation_time(tmp_path): path = task_lease_path(runtime_root=paths["runtime"], goal_id=GOAL_ID, todo_id="todo_lane_a") path.write_text(json.dumps({**lease, "lease_epoch": 2}), encoding="utf-8") after = project_shared_goal_alignment(**arguments)["source_basis"] - assert before["source_basis_digest"] != after["source_basis_digest"] + assert before["source_basis_digest"] == after["source_basis_digest"] assert before["state_updated_at"] == after["state_updated_at"] assert paths["state_file"].read_bytes() == state_bytes -def test_legacy_basis_ignores_display_order_of_identical_work(tmp_path): +def test_canonical_basis_ignores_display_order_of_identical_work(tmp_path): specs = _default_todo_specs() paths = _write_fixture(tmp_path, todo_specs=specs) arguments = dict(goal_id=GOAL_ID, agent_id="agent-a", project=paths["project"], diff --git a/tests/control_plane/test_start_goal_compact_projection.py b/tests/control_plane/test_start_goal_compact_projection.py index 481c9a24c1..234e9e30fa 100644 --- a/tests/control_plane/test_start_goal_compact_projection.py +++ b/tests/control_plane/test_start_goal_compact_projection.py @@ -40,17 +40,20 @@ def _isolate_ambient_ssh_transport(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("SSH_CLIENT", raising=False) -def _write_connected_project(root: Path) -> Path: +def _write_connected_project(root: Path, *, todos_body: str | None = None) -> Path: project = root / "project" state_file = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" state_file.parent.mkdir(parents=True) - state_file.write_text("# Active Goal State\n", encoding="utf-8") + state_file.write_text("# Active Goal State\n" + ( + f"## Objective\n{GOAL_TEXT}\n## Agent Todo\n{todos_body}\n" + if todos_body is not None else ""), encoding="utf-8") registry = project / ".loopx" / "registry.json" registry.parent.mkdir(parents=True) registry.write_text( json.dumps( { "schema_version": "0.1", + "common_runtime_root": str(root / "runtime"), "goals": [ { "id": GOAL_ID, @@ -69,6 +72,17 @@ def _write_connected_project(root: Path) -> Path: + "\n", encoding="utf-8", ) + from canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source + from loopx.control_plane.todos.todo_summary import canonical_todo_read_record, structured_todo_item + + active, _, _ = parse_todo_source(state_file.read_text()) + records = [canonical_todo_read_record(structured_todo_item( + item, role="agent", source_section="Agent Todo", text_limit=None, + )) for item in active["agent"]] + initialize_canonical_authority(root / "runtime", GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=records), state_path=state_file) return project @@ -1104,7 +1118,7 @@ def test_start_goal_binds_selected_lane_before_todo_writeback( project = _write_connected_project(tmp_path) source_registry = project / ".loopx" / "registry.json" runtime = tmp_path / "runtime" - runtime.mkdir() + runtime.mkdir(exist_ok=True) source_payload = json.loads(source_registry.read_text(encoding="utf-8")) source_payload["common_runtime_root"] = str(runtime) source_registry.write_text( @@ -1177,7 +1191,7 @@ def test_fresh_agent_registration_preserves_guided_runtime_root( project = _write_connected_project(tmp_path) source_registry = project / ".loopx" / "registry.json" runtime = tmp_path / "runtime" - runtime.mkdir() + runtime.mkdir(exist_ok=True) source_payload = json.loads(source_registry.read_text(encoding="utf-8")) source_payload["common_runtime_root"] = str(runtime) source_payload["goals"][0]["coordination"]["registered_agents"] = [] @@ -1309,6 +1323,12 @@ def test_guided_state_commands_share_explicit_runtime_root( project = _write_connected_project(tmp_path) runtime = project / "explicit-runtime" runtime.mkdir() + from canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[]), + state_path=project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md") payload = build_start_goal_guided_packet( project=project, @@ -2113,17 +2133,7 @@ def _write_connected_project_with_todos( *, todos_body: str, ) -> Path: - project = _write_connected_project(root) - state_file = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" - state_file.write_text( - "# Active Goal State\n" - "## Objective\n" - f"{GOAL_TEXT}\n" - "## Agent Todo\n" - f"{todos_body}\n", - encoding="utf-8", - ) - return project + return _write_connected_project(root, todos_body=todos_body) def _write_connected_project_with_runnable_agent_todo(root: Path) -> Path: diff --git a/tests/control_plane/test_start_goal_orphaned_goal_state.py b/tests/control_plane/test_start_goal_orphaned_goal_state.py index f9c60dbf94..99f9552530 100644 --- a/tests/control_plane/test_start_goal_orphaned_goal_state.py +++ b/tests/control_plane/test_start_goal_orphaned_goal_state.py @@ -75,7 +75,8 @@ def _project( registry_path.write_text('{"schema_version": "0.1", "goals": [', encoding="utf-8") elif registry != "missing": registry_path.write_text( - json.dumps({"schema_version": "0.1", "goals": entries}, indent=2) + "\n", + json.dumps({"schema_version": "0.1", "goals": entries, + "common_runtime_root": str(root / "runtime")}, indent=2) + "\n", encoding="utf-8", ) registered_state = project / ".codex" / "goals" / REGISTERED_GOAL_ID @@ -89,6 +90,13 @@ def _project( (orphan / "ACTIVE_GOAL_STATE.md").write_text( "# Orphaned goal state written by a retired lane\n", encoding="utf-8" ) + if registry == "registered": + from canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + initialize_canonical_authority(root / "runtime", REGISTERED_GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=REGISTERED_GOAL_ID, todos=[]), + state_path=registered_state / "ACTIVE_GOAL_STATE.md") return project diff --git a/tests/control_plane/test_unmigrated_goal_reads.py b/tests/control_plane/test_unmigrated_goal_reads.py new file mode 100644 index 0000000000..f7720c3b5f --- /dev/null +++ b/tests/control_plane/test_unmigrated_goal_reads.py @@ -0,0 +1,105 @@ +"""Ordinary reads require migration; explicit cold import retains source access.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from loopx.control_plane.coordination.legacy_writer_fence import ( + legacy_coordination_writer_fence_path, +) +from test_cold_source_import_cli import canonical_todos, workspace + + +def _cli(tmp_path: Path, runtime: Path, receiver: Path, env: dict, *args: str): + child = subprocess.run( + [sys.executable, "-c", + "import loopx,runpy,sys; print(loopx.__file__,file=sys.stderr); " + "runpy.run_module('loopx.cli',run_name='__main__')", + "--registry", str(tmp_path / "project/.loopx/registry.json"), + "--runtime-root", str(runtime), "--format", "json", *args], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60, + ) + assert str(receiver / "loopx/__init__.py") in child.stderr + assert "Traceback" not in child.stderr, child.stderr + return child.returncode, json.loads(child.stdout) + + +def _reason_codes(value): + if isinstance(value, dict): + return { + code for key, child in value.items() + for code in ([child] if key in {"code", "reason_code", "error_code"} + and isinstance(child, str) else _reason_codes(child)) + } + if isinstance(value, list): + return set().union(*(_reason_codes(child) for child in value)) + return set() + + +@pytest.mark.parametrize("args", [ + ("todo", "list", "--goal-id", "cold"), + ("status", "--goal-id", "cold"), + ("task-lease", "inspect", "--goal-id", "cold", "--todo-id", "todo_current"), + ("quota", "should-run", "--goal-id", "cold", "--agent-id", "agent-a", "--codex-app"), +]) +def test_ordinary_cli_refuses_unmigrated_goal_without_importing_it( + tmp_path, monkeypatch, args, +): + _, state, _, _, runtime, receiver, env = workspace(tmp_path, monkeypatch) + registry_path = tmp_path / "project/.loopx/registry.json" + registry = json.loads(registry_path.read_text()) + registry["goals"][0].update( + domain="software", adapter={"kind": "read_only_project_map_v0"}, + ) + registry_path.write_text(json.dumps(registry)) + original = state.read_bytes() + _, result = _cli(tmp_path, runtime, receiver, env, *args) + assert "canonical_authority_required" in _reason_codes(result), result + assert state.read_bytes() == original + assert not legacy_coordination_writer_fence_path( + runtime_root=runtime, goal_id="cold", + ).exists() + assert not (runtime / "authority").exists() + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_explicit_import_retains_full_source_and_enables_ordinary_reads( + tmp_path, monkeypatch, provider, +): + cli, state, backup, body, runtime, receiver, env = workspace(tmp_path, monkeypatch) + original = state.read_bytes() + prepared = cli( + "prepare-import", "--backup-manifest", backup["manifest_path"], + "--provider", provider, "--target-handoff-mode", "hard_lease", + )["cold_import"] + assert prepared["status"] == "prepared", prepared + assert state.read_bytes() == original + applied = cli( + "apply-import", "--plan-sha256", prepared["plan_sha256"], + "--writers-stopped", "--execute", + )["cold_import"] + assert applied["status"] == "applied", applied + assert applied["execution_authority_granted"] is False + assert state.read_bytes() == original + snapshot = canonical_todos(runtime, receiver, env, tmp_path) + records = {row["todo_id"]: row for row in snapshot["todos"]} + assert set(records) == {"todo_current", "todo_archived"} + assert records["todo_current"]["text"] == body + assert records["todo_archived"]["evidence"] == "original" + code, listed = _cli( + tmp_path, runtime, receiver, env, + "todo", "list", "--goal-id", "cold", "--todo-id", "todo_current", + ) + assert code == 0 and listed["ok"], listed + assert listed["todo"]["text"] == body + assert listed["authority_read"]["legacy_fallback_used"] is False + replay = cli( + "recover-import", "--plan-sha256", prepared["plan_sha256"], "--execute", + )["cold_import"] + assert replay["status"] == "replayed", replay + assert replay["cursor"] == applied["cursor"] + assert canonical_todos(runtime, receiver, env, tmp_path) == snapshot diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index 19985054d0..3138e7b1eb 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -32,6 +32,7 @@ import {registerAuthoritySourceConformance} from "./authority_source_conformance import {registerNativePlanningUpdateConformance} from "./native_planning_update_conformance.ts"; import {registerCompletionValidationBindingConformance} from "./completion_validation_binding_conformance.ts"; import {coordinationCommandFixture} from "./coordination_command_fixture.ts"; +import {canonicalReadRoot} from "./local_promotion_fixture.ts"; import type { AuthorityStore, @@ -275,7 +276,7 @@ export function registerAuthorityStoreConformance( operation_id: "capture-source", events: [], next_projection: captured, receipts: []}); assert.equal(result.status, "applied"); const read = await listLocalCoordinationTodos({schema_version: LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, - runtime_root: "/unused", goal_id: goalId, include_leases: true}, {createStore: () => contender}); + runtime_root: await canonicalReadRoot(t, goalId), goal_id: goalId, include_leases: true}, {createStore: () => contender}); assert.equal(read.status, "loaded"); assert.deepEqual(read.todos, captured.todos); assert.deepEqual(read.leases, captured.leases); @@ -590,7 +591,7 @@ export function registerAuthorityStoreConformance( expected_provider_revision: null, events: [], receipts: [], next_projection: projection}); assert.equal(seeded.status, "applied"); const request = {schema_version: LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, - runtime_root: "/synthetic-runtime", goal_id: goal}; + runtime_root: await canonicalReadRoot(t, goal), goal_id: goal}; const plain = await listLocalCoordinationTodos(request, {createStore: () => store}); assert.equal(plain.status, "loaded"); assert.equal(Object.hasOwn(plain, "leases"), false); // Default-off response parity. diff --git a/tests/control_plane_ts/canonical_snapshot_page.test.ts b/tests/control_plane_ts/canonical_snapshot_page.test.ts index ca4b831441..82eb1f2c6d 100644 --- a/tests/control_plane_ts/canonical_snapshot_page.test.ts +++ b/tests/control_plane_ts/canonical_snapshot_page.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {mkdtemp, rm, readdir} from "node:fs/promises"; +import {mkdtemp, rm, readdir, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; @@ -16,6 +16,10 @@ import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/a import {goalAcceptanceTodoDigest, normalizeGoalAcceptanceDocument} from "../../loopx/control_plane/goals/acceptance_contract.ts"; import {reviseGoalObjective} from "../../loopx/control_plane/goals/goal_objective.ts"; import {collectSnapshot, registerCanonicalSnapshotConformance, seedSnapshot, snapshotRequest, snapshotFixture} from "./canonical_snapshot_conformance.ts"; +import {engageCanonicalRoute} from "./local_promotion_fixture.ts"; +import {legacyCoordinationWriterFencePath} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {listLocalCoordinationTodos, readLocalCoordinationTodo} from "../../loopx/control_plane/coordination/local_authority_read.ts"; +import {observeLocalCoordinationOwnership} from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; async function fixture(t: test.TestContext, kind: "file" | "sqlite" = "file") { const root = await mkdtemp(join(tmpdir(), "canonical-snapshot-")); @@ -117,6 +121,7 @@ for (const position of [null, -1, true, "128", 0.5]) { test("a missing runtime provider read does not initialize a replacement authority", async t => { const root = await mkdtemp(join(tmpdir(), "canonical-missing-")); t.after(() => rm(root, {recursive: true, force: true})); + await engageCanonicalRoute(root); const before = await readdir(root); const result = await readCanonicalSnapshotPage(snapshotRequest({runtime_root: root})); assert.equal(result.status, "missing"); @@ -127,6 +132,36 @@ test("a missing runtime provider read does not initialize a replacement authorit assert.deepEqual(await readdir(root), before); }); +for (const fence of ["missing", "corrupt"] as const) { + test(`ordinary current reads reject a ${fence} cutover before opening any provider`, async t => { + const root = await mkdtemp(join(tmpdir(), "unmigrated-read-")); + t.after(() => rm(root, {recursive: true, force: true})); + if (fence === "corrupt") { + await engageCanonicalRoute(root); + await writeFile(legacyCoordinationWriterFencePath(root, "goal-a"), "{"); + } + const before = await readdir(root); + const dependencies = {createStore: () => assert.fail("unadmitted read opened a provider")}; + const results = [ + await readCanonicalSnapshotPage(snapshotRequest({runtime_root: root}), dependencies), + await listLocalCoordinationTodos({schema_version: "loopx_local_coordination_todo_list_request_v0", + runtime_root: root, goal_id: "goal-a"}, dependencies), + await readLocalCoordinationTodo({schema_version: "loopx_local_coordination_todo_read_request_v0", + runtime_root: root, goal_id: "goal-a", todo_id: "todo_a"}, dependencies), + await observeLocalCoordinationOwnership({schema_version: "loopx_local_ownership_observation_request_v0", + runtime_root: root, goal_id: "goal-a", observed_at: "2026-09-29T00:00:00Z"}), + ]; + for (const result of results) { + assert.equal(result.reason_code, fence === "missing" + ? "canonical_authority_required" : "canonical_authority_fence_invalid"); + assert.equal(result.decision_read_from_provider, false); + assert.equal(result.legacy_fallback_used, false); + assert.equal("todos" in result, false); + } + assert.deepEqual(await readdir(root), before); + }); +} + test("ordering uses Unicode code points rather than JavaScript default UTF-16 sorting", async t => { const {store} = await fixture(t); const projection = snapshotFixture().projection; diff --git a/tests/control_plane_ts/coordination_projection.test.ts b/tests/control_plane_ts/coordination_projection.test.ts index ef225065c8..22e533ffdf 100644 --- a/tests/control_plane_ts/coordination_projection.test.ts +++ b/tests/control_plane_ts/coordination_projection.test.ts @@ -17,6 +17,7 @@ import { TODO_CANONICAL_READ_RECORD_SCHEMA, } from "../../loopx/control_plane/coordination/coordination_projection.ts"; import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {engageCanonicalRoute} from "./local_promotion_fixture.ts"; import {LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; import {listLocalCoordinationTodos} from "../../loopx/control_plane/coordination/local_authority_read.ts"; import { @@ -27,6 +28,7 @@ import { test("native provider Todo creation and archival need no Markdown address", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-domain-todo-")); + await engageCanonicalRoute(root); const store = new FileAuthorityStore(root, "goal-a"); const initial = await store.commitAuthority({ expected_provider_revision: null, @@ -393,6 +395,7 @@ for (const native of [false, true]) { assert.throws(() => validateCoordinationTodoReadModel({...head, todo_read_model: {...head.todo_read_model, todo_count: 2}}, head.goal_id), /count mismatch/); const root = await mkdtemp(join(tmpdir(), "loopx-upgrade-todo-")); + await engageCanonicalRoute(root, head.goal_id); const store = new FileAuthorityStore(root, head.goal_id); const initial = await store.commitAuthority({expected_provider_revision: null, operation_id: "historical-head", events: [{schema_version: "bootstrap_v0"}], diff --git a/tests/control_plane_ts/goal_acceptance_runtime.test.ts b/tests/control_plane_ts/goal_acceptance_runtime.test.ts index 9f006fd0f0..7492567866 100644 --- a/tests/control_plane_ts/goal_acceptance_runtime.test.ts +++ b/tests/control_plane_ts/goal_acceptance_runtime.test.ts @@ -22,6 +22,7 @@ import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordinat import {executeCanonicalTaskLeaseAcquire} from "../../loopx/control_plane/coordination/task_lease_acquire.ts"; import {executeCoordinationTodoTerminalLifecycle, type CoordinationTodoTerminalLifecycleInput} from "../../loopx/control_plane/coordination/todo_terminal_lifecycle.ts"; import {canonicalTodoCollection, listLocalCoordinationTodos, readLocalCoordinationTodo} from "../../loopx/control_plane/coordination/local_authority_read.ts"; +import {engageCanonicalRoute} from "./local_promotion_fixture.ts"; const now = new Date("2026-09-17T12:00:00Z"); const todo = (extra: JsonObject = {}): JsonObject => ({schema_version: "todo_domain_record_v0", @@ -286,6 +287,7 @@ for (const provider of ["file", ...(process.env.LOOPX_TEST_POSTGRES_URL ? ["post test("canonical list/read expose only enabled public-safe acceptance sidecars", async t => { const {store, root} = await seeded(t, "file", "bound"); + await engageCanonicalRoute(root); const records = [todo(), todo({todo_id: "todo_new", text: "New work"}), todo({todo_id: "todo_monitor", task_class: "continuous_monitor"}), todo({todo_id: "todo_gate", role: "user", task_class: "user_gate"})]; diff --git a/tests/control_plane_ts/local_authority_provider.test.ts b/tests/control_plane_ts/local_authority_provider.test.ts index f150ee010e..adf37fc982 100644 --- a/tests/control_plane_ts/local_authority_provider.test.ts +++ b/tests/control_plane_ts/local_authority_provider.test.ts @@ -16,7 +16,7 @@ import { authorityStoreCommitFixture } from "./authority_store_conformance.ts"; import * as mutations from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; import * as reads from "../../loopx/control_plane/coordination/local_authority_read.ts"; const runtime = {...mutations, ...reads}; -import { qualifiedShadow, promotionRequest, engageFence } from "./local_promotion_fixture.ts"; +import { qualifiedShadow, promotionRequest, engageFence, engageCanonicalRoute } from "./local_promotion_fixture.ts"; import { engageLegacyCoordinationWriterFence, loadLegacyCoordinationWriterFence, @@ -252,6 +252,7 @@ test("a PostgreSQL selector is a service-owned, identity-fenced switch", async t assert.equal(handle.provider, "postgresql"); assert.equal(handle.sourceAuthority, "postgresql_v0"); assert.equal(handle.store, store); + await engageCanonicalRoute(directory, "goal"); const listed = await runtime.listLocalCoordinationTodos({ schema_version: runtime.LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, runtime_root: directory, diff --git a/tests/control_plane_ts/local_authority_runtime.test.ts b/tests/control_plane_ts/local_authority_runtime.test.ts index 32f8d8c0ef..ca393c328c 100644 --- a/tests/control_plane_ts/local_authority_runtime.test.ts +++ b/tests/control_plane_ts/local_authority_runtime.test.ts @@ -60,7 +60,7 @@ import { bootstrapCoordinationRuntimeShadow, COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, } from "../../loopx/control_plane/coordination/runtime_shadow.ts"; -import { qualifiedShadow, promotionRequest, engageFence } from "./local_promotion_fixture.ts"; +import { qualifiedShadow, promotionRequest, engageFence, engageCanonicalRoute } from "./local_promotion_fixture.ts"; import { sourceRequest } from "./shadow_file_fixture.ts"; import { LOCAL_COORDINATION_PROMOTION_REVIEW_REQUEST_SCHEMA } from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; import { executeTaskLeaseAcquire } from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; @@ -124,18 +124,6 @@ function noteFacts(note: unknown): string { return canonicalAuthoritySha256(parsed); } -async function engageCanonicalRoute(root: string): Promise { - await atomicWriteJson(legacyCoordinationWriterFencePath(root, "goal-a"), { - schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, - state: "engaged", - goal_id: "goal-a", - fence_id: "legacy-writer-fence:goal-a:test", - source_version: "state:test", - source_projection_sha256: "a".repeat(64), - expected_shadow_provider_revision: `file:1:${"a".repeat(24)}`, - }); -} - async function claimSeededTodo( root: string, todo: Record, @@ -674,6 +662,7 @@ test("new bootstrap and provider list fail closed without exact Todo consumer se receipts: [], }); assert.equal(committed.status, "applied"); + await engageCanonicalRoute(root); const listed = await listLocalCoordinationTodos({ schema_version: LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, runtime_root: root, @@ -1538,6 +1527,7 @@ test("provider-first atomic claim replays current execution and rejects expired test("local canonical runtime never falls back when provider state is missing", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-missing-")); + await engageCanonicalRoute(root); const result = await readLocalCoordinationTodo({ schema_version: LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA, runtime_root: root, diff --git a/tests/control_plane_ts/local_promotion_fixture.ts b/tests/control_plane_ts/local_promotion_fixture.ts index 71323959ee..0b6eaab1e7 100644 --- a/tests/control_plane_ts/local_promotion_fixture.ts +++ b/tests/control_plane_ts/local_promotion_fixture.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; -import { writeFile } from "node:fs/promises"; +import { writeFile, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; +import {tmpdir} from "node:os"; +import type {TestContext} from "node:test"; import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; import { canonicalAuthoritySha256 as sha256 } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import { @@ -13,6 +15,25 @@ import { bootstrapCoordinationRuntimeShadow, COORDINATION_RUNTIME_SHADOW_BOOTSTR import { projection as fileProjection, sourceRequest, pendingEntry, settleFiles } from "./shadow_file_fixture.ts"; import {deliverShadowEntry} from "../../loopx/control_plane/coordination/shadow_entry_delivery.ts"; import {entrySelection} from "./shadow_file_fixture.ts"; +import {atomicWriteJson} from "../../loopx/control_plane/effect_runtime_io.ts"; +import {legacyCoordinationWriterFencePath} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; + +/** Synthetic completed route for tests that seed a provider directly. */ +export async function engageCanonicalRoute(root: string, goalId = "goal-a"): Promise { + await atomicWriteJson(legacyCoordinationWriterFencePath(root, goalId), { + schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, + state: "engaged", goal_id: goalId, fence_id: "canonical-test-route", + source_version: "state:test", source_projection_sha256: "a".repeat(64), + expected_shadow_provider_revision: `file:1:${"a".repeat(24)}`, + }); +} + +export async function canonicalReadRoot(context: TestContext, goalId: string): Promise { + const root = await mkdtemp(join(tmpdir(), "canonical-read-route-")); + context.after(() => rm(root, {recursive: true, force: true})); + await engageCanonicalRoute(root, goalId); + return root; +} function todoRecord(overrides: Record = {}) { return {schema_version: "todo_item_v0", todo_id: "todo_a", role: "agent", status: "open", diff --git a/tests/control_plane_ts/periodic_report_conformance.ts b/tests/control_plane_ts/periodic_report_conformance.ts index cf1ae6f4f5..011661fed1 100644 --- a/tests/control_plane_ts/periodic_report_conformance.ts +++ b/tests/control_plane_ts/periodic_report_conformance.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import {canonicalReadRoot} from './local_promotion_fixture.ts'; import type {AuthorityStoreConformanceFactory} from './authority_store_conformance.ts'; import {productionScaleCoordinationFixture} from './production_scale_coordination_fixture.ts'; import {coordinationTodoReadModel} from '../../loopx/control_plane/coordination/coordination_projection.ts'; @@ -28,7 +29,7 @@ export function registerPeriodicReportConformance(name: string, factory: Authori assert.equal(seeded.status,'applied'); const before = await store.loadAuthority(); const source = await listLocalCoordinationTodos({schema_version:LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, - goal_id:goal,runtime_root:'/synthetic-runtime'}, {createStore:()=>store}); + goal_id:goal,runtime_root:await canonicalReadRoot(context,goal)}, {createStore:()=>store}); assert.equal(source.status,'loaded'); const records = source.todos as JsonObject[]; assert.equal(records.length,fixture.expected_initial_todo_count); diff --git a/tests/control_plane_ts/projection_confirmation_conformance.ts b/tests/control_plane_ts/projection_confirmation_conformance.ts index 40d9b5421c..1e2a3feeb1 100644 --- a/tests/control_plane_ts/projection_confirmation_conformance.ts +++ b/tests/control_plane_ts/projection_confirmation_conformance.ts @@ -5,6 +5,7 @@ import type {AuthorityStoreConformanceFactory} from "./authority_store_conforman import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; import {listLocalCoordinationTodos, readLocalCoordinationTodo} from "../../loopx/control_plane/coordination/local_authority_read.ts"; import {LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import {canonicalReadRoot} from "./local_promotion_fixture.ts"; export function registerProjectionConfirmationConformance(name: string, factory: AuthorityStoreConformanceFactory): void { for (const shape of ["native", "legacy"] as const) test(`${name}: projection confirmation observes current full source (${shape})`, async context => { @@ -20,7 +21,8 @@ export function registerProjectionConfirmationConformance(name: string, factory: next_projection: fixture.projection, events: [], receipts: []}); assert.equal(seeded.status, "applied"); const dependencies = {createStore: () => store}; - const request = {schema_version: LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, goal_id: goal, runtime_root: "/synthetic-runtime"}; + const request = {schema_version: LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, goal_id: goal, + runtime_root: await canonicalReadRoot(context, goal)}; const plain = await listLocalCoordinationTodos(request, dependencies); assert.equal(plain.status, "loaded"); assert.equal(Object.hasOwn(plain, "projection_readback"), false);