diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md index a6a27c1f34..9bd22fbbf6 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.md @@ -63,6 +63,72 @@ lost/unknown work and human interventions recorded. Keep service and additional platform profiles with M4/M5. This coordination checkpoint implements no M2/M3 behavior and starts no recovery or activation. + + +### Planned agent transfer: proposed product refinement + +The ordinary user request is **move my Agent to another environment and continue +its unfinished work**. Preparing that move while the source is reachable can be +prioritized before disaster recovery. This proposed delivery refinement reuses +the owners below; it does not rename M1 inspection as migration, relax M2/M3's +accepted disaster-recovery gates, or claim a working transfer today. + +Start with one populated local Goal and its responsible Agent on a declared +packaged POSIX File/SQLite profile. Establish that profile's required owners and +source-stop support, then deliver this whole journey through the existing +Settings/Capability Center and shared CLI/backend: + +1. Prepare the source, its complete requirements and required materials; let + their owners settle or visibly hold in-flight work and establish quiescence. +2. Produce a separately reviewed portable artifact. The raw private backup is + not that artifact: apply the encryption/credential-custody decision below, + preserve content and history, and identify dependencies that need rebind. +3. On the destination, preview paths, Goal/Agent identity mapping, dependencies, + compatibility, missing context and work outside the captured boundary. The + Agent prepares these facts; users do not edit registries or stitch owner + commands together. Ask only for missing consequential choices or credentials. +4. Confirm the prepared cutover once. Existing Goal, authority, session, lease, + quota and effect owners must admit the destination and exclude stale source + execution. A copied Host/session id or released lease is not that admission. +5. Continue a task left unfinished before transfer; independently accept its + result and read it back from the initiating product entry. Preserve later + writes, and expose interruption/retry/rollback there as part of the journey. + +The transferred work includes Goal intent/constraints, full active and archived +Todos and relations, Agent role and necessary decision context, required +artifacts/materials and capability configuration. Use a supported Host export +or reviewed continuation context; do not claim that copying a third-party +session database resumes that session. Identity continuity versus a new lifetime +must be explicit in the Goal owner's plan; historical receipt bodies stay +unaltered and do not become fresh execution proof. Unknown external effects +hold with their original operation identity instead of being redispatched. + +The implementation boundary is the **prepared transfer through destination +continuation**, including its product entry and real failure/recovery path. +Select repairs by a demonstrated break in that journey. Do not schedule a +manifest-field series first or require every cloud/platform profile and D2's +ten-day default qualification before delivering the first supported transfer. +The selected profile still needs all of its actual dependencies and safety +gates; an unused provider needs owner-confirmed absence, not an exclude flag. + +Current pieces and the remaining integration are concrete: + +| Existing owner / entry | Reuse and remaining work | +| --- | --- | +| `backup-state`, configuration backup and authority archive | Reuse actual content export, safe extraction and original-receipt audit. Build the portable transfer composition over them; an inert destination is not a registered, executable Goal. | +| `migrate-state` | Legacy Goal Harness copy only. Its preview and execution both reject canonical source/target authority through the existing fence owner; it is not a modern Agent transfer shortcut. | +| Goal/session/Host and Task/lease owners | Prepare destination bindings and executable Task declarations through their actual lifecycle. Retain old lineage, reject stale executors, and rebind supported sessions; metadata copying cannot repair or mint a grant. | +| Settings/Capability Center and CLI | One prepared operation with preview, confirmation, visible holds, same-operation retry and continued-work readback. Do not expose eight independent owner runbooks as the ordinary user workflow. | + +Before the drill, freeze the supported profile, data/effect disposition and +targets for elapsed time, required user input, navigation and recovery effort. +Use two isolated packaged environments with completed and unfinished work plus +real required materials. Cover stale/changed source, damaged input, missing +materials, occupied destination, interruption and duplicate confirmation; +protected duplicate operations must remain zero. A successful planned transfer +does not qualify unavailable-source M3 recovery. This refinement adds no +activation, credential-transfer, paid experiment or release authorization. + ### First supported user outcome The first bounded user promise is recovery of one local Goal after its source diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md index 19302c2efa..eeca48b820 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md @@ -48,6 +48,57 @@ held,未知状态不改称可恢复,执行权限始终为 false。限制与 未完成工作结果,记录已恢复、丢失/未知工作和人工介入。服务及额外平台 profile 继续由 M4/M5 承接。本协调检查点未实现 M2/M3,也不启动恢复或激活。 + + +### 计划内 Agent 迁移:产品交付细化提案 + +普通用户要完成的是**把我的 Agent 搬到另一个环境,并继续原来未完成的工作**。 +源端仍可联系时的计划内迁移,可以优先于灾难恢复交付。本提案复用下述既有 owner, +不把 M1 检查改称迁移,不降低 M2/M3 已接受的灾难恢复门,也不声称现在已经能完成迁移。 + +从一个有真实内容的本地 Goal 及其负责 Agent 开始,明确正常安装的 POSIX +File/SQLite profile、实际必要 owner 和源端停止支持,然后通过现有设置/能力中心 +及共享 CLI/backend 交付完整旅程: + +1. 准备源端、完整要求和必要材料,由各自 owner 结算或明确 hold 进行中的工作, + 并建立静止边界。 +2. 生成单独评审的可携产物。原始私有备份不等于这个产物;落实下文的加密与凭证 + 保管决定,保留内容和历史,并识别需重绑的依赖。 +3. 目标端预览路径、Goal/Agent 身份映射、依赖、兼容性、缺失上下文和捕获边界外的 + 工作。Agent 先准备这些事实,不让用户编辑 registry 或拼接各 owner 命令;只为 + 缺失的重要选择或凭证请求输入。 +4. 对已准备的切换进行一次确认。既有 Goal、authority、session、lease、quota 与 + effect owner 必须准入目标并排除源端过期执行;复制 Host/session id 或已释放 + lease 不等于取得准入。 +5. 继续完成迁移前留下的任务,独立验收其结果,并从发起产品入口读回。保留后写, + 同入口提供中断、重试和回退,这些都属于迁移旅程。 + +迁移内容包括 Goal 意图/约束、完整活动与归档 Todo 及关系、Agent 角色和必要决策 +上下文、必要产物/材料和能力配置。采用受支持的 Host 导出或经评审的接续上下文, +不声称复制第三方 session 数据库就能恢复该 session。延续身份还是建立新 lifetime +须由 Goal owner 的计划明确;原历史 receipt 正文不变,也不成为新的 execution proof。 +外部 effect 结果未知时,携原 operation identity 保持 hold,不重新派发。 + +实现边界是**已准备的迁移到目标继续工作**,包括产品入口和真实失败/恢复路径。 +按这条旅程的实证断点选择修复,不先排一串 manifest 字段,也不要求所有云/平台 +profile 及 D2 十日默认资格先完成。所选 profile 自身的实际依赖和安全门仍须成立; +未用 provider 需要 owner 证实不存在依赖,不能用 exclude flag 代替。 + +既有基础与剩余整合的归属如下: + +| 既有 owner / 入口 | 复用及剩余工作 | +| --- | --- | +| `backup-state`、configuration backup 与 authority archive | 复用实际内容导出、安全解包和原 receipt 审计,在其上组合可携迁移。隔离可读目标不等于已注册、可执行的 Goal。 | +| `migrate-state` | 只复制旧 Goal Harness 状态。预览与执行均通过既有 fence owner 拒绝 canonical 源/目标,不是现代 Agent 迁移捷径。 | +| Goal/session/Host 与 Task/lease owner | 通过真实生命周期准备目标绑定和可执行 Task 声明,保留旧 lineage、拒绝过期 executor、重绑受支持 session;复制元数据不能修复或铸造 grant。 | +| 设置/能力中心与 CLI | 一次已准备的 operation,包含预览、确认、可见 hold、原操作重试和继续工作读回。不把八套 owner runbook 作为普通用户流程。 | + +演练前冻结支持 profile、数据/effect 处置,以及耗时、必要用户输入、导航和恢复 +负担目标。使用两个隔离的正常安装环境,包含已完成/未完成工作及真实必要材料。 +覆盖源变化/过期、输入损坏、缺材料、目标占用、中断和重复确认;受保护重复操作 +必须为零。计划内迁移成功不证明原源不可用的 M3 恢复合格。本提案不新增激活、 +凭证转移、付费实验或发布授权。 + ### 首个支持的用户结果 首个有界用户承诺是:一个本地 Goal 的来源工作区不可用或不再可信后,用户可以恢复 diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index fd420e5180..f5ec93c78e 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -985,7 +985,8 @@ The dated source checkpoints below must not regenerate completed writer work. | Existing milestone / priority | Next independently reviewable outcome | Acceptance and continuation | | --- | --- | --- | -| T4/C1 + D1 · P1, first | Qualify one pinned final-main source and its normal installed CLI/App/affected local Host upgrade journey, including #6597 restart recovery | Compare complete active/archive records, claims, lease epochs, original receipts and later writes. Exercise expired-but-unreleased leases, stale preview/proof, unavailable provider and interrupted apply; rejection leaves state unchanged. Exit through reviewed migration preserving new writes. Reuse the installed repair evidence only after source/caller applicability checks. | +| Planned Agent transfer · proposed P0 product refinement | A user prepares a reachable source, transfers one populated Goal and its responsible Agent to a declared packaged local profile, and continues unfinished work through the initiating product entry | Follow the [prepared-transfer journey](complete-state-recovery-v0.md#planned-agent-transfer), including required context/materials, destination admission, stale-source exclusion and same-operation failure recovery. Qualify actual profile dependencies and portable-artifact protection; do not wait for all cloud/platform profiles or turn backup inspection, TS deletion or D2 default qualification into the user result. This is a proposed next delivery, not an implemented transfer or activation grant. | +| T4/C1 + D1 · P1, companion | Qualify one pinned final-main source and its normal installed CLI/App/affected local Host upgrade journey, including #6597 restart recovery | Compare complete active/archive records, claims, lease epochs, original receipts and later writes. Exercise expired-but-unreleased leases, stale preview/proof, unavailable provider and interrupted apply; rejection leaves state unchanged. Exit through reviewed migration preserving new writes. Reuse the installed repair evidence only after source/caller applicability checks. | | Complete recovery M2 → M3 · P1 | Declare capture consistency for one supported local profile, then recover one Goal whose source is unavailable and continue its unfinished work | Reuse manifest/registry/Goal/provider/session/effect owners. Freeze the decision-owner matrix and RPO/RTO before the drill; reconcile pending effects and destination identities. M1 inspection remains inert; M3 requires admitted owners, zero protected duplicate operations, visible holds and a separately accepted result at the initiating product entry. | | Goal lifetime M2–M5 / #5206 · P1 dependency only where exercised | Finish the selected profile's remaining old/warm-writer and effect-drain fences, then its orphan/retirement-recreation journey | A removed source writer does not exclude installed 1.x binaries or cached executors. Old A must not write, deliver, debit or settle same-alias B; B and unrelated work continue. Keep activation held until the profile's required owner rows pass; do not rebuild the shipped codec or source-session transaction. | | D2 · P1 alongside the relevant implementation | Explain the remaining complete-command/history-growth costs on matched File/SQLite workloads and reconcile formal volume/elapsed evidence | Separate caller/bridge, shared typed transaction and provider costs. Preserve current-release and pre-migration controls, frozen failures/missing axes, durability, recovery space and ten-day/volume requirements. Do not restart valid elapsed evidence for an unrelated change or launch a new soak from this plan. | diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index cf483597d2..abe64edcaf 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -678,7 +678,8 @@ writer 工作。 | 既有里程碑/优先级 | 下一独立可评审结果 | 验收与后续 | | --- | --- | --- | -| T4/C1 + D1 · P1,先做 | 冻结最终 main 源码及正常安装的 CLI/App/受影响本地 Host 升级旅程,包含 #6597 重启恢复 | 比较完整 active/archive、claim、lease epoch、原 receipt 和后写;覆盖过期未释放租约、旧 preview/proof、provider 不可用、中断 apply,拒绝时状态不变;通过审核迁移退出且保留新写。既有安装修复证据先核对源码/caller 适用性再复用。 | +| 计划内 Agent 迁移 · P0 产品细化提案 | 用户准备可联系的源端,把一个有真实内容的 Goal 及其负责 Agent 搬到明确的正常安装本地 profile,并从发起产品入口继续未完成工作 | 按[已准备的迁移旅程](complete-state-recovery-v0.zh-CN.md#planned-agent-transfer)交付必要上下文/材料、目标准入、过期源排除及原操作失败恢复。所选 profile 的实际依赖和可携产物保护须合格;不等待全部云/平台 profile,不把备份检查、TS 删行或 D2 默认资格当用户结果。这是下一交付提案,不代表迁移已实现或授予激活。 | +| T4/C1 + D1 · P1,伴随 | 冻结最终 main 源码及正常安装的 CLI/App/受影响本地 Host 升级旅程,包含 #6597 重启恢复 | 比较完整 active/archive、claim、lease epoch、原 receipt 和后写;覆盖过期未释放租约、旧 preview/proof、provider 不可用、中断 apply,拒绝时状态不变;通过审核迁移退出且保留新写。既有安装修复证据先核对源码/caller 适用性再复用。 | | 完整恢复 M2 → M3 · P1 | 为一个受支持本地 profile 声明捕获一致性,再从不可用来源恢复一个 Goal 并继续未完成工作 | 复用 manifest/registry/Goal/provider/session/effect owner;演练前冻结 decision-owner 矩阵和 RPO/RTO,对账 pending effect 与目标身份。M1 检查仍隔离无执行权;M3 须 owner 准入、受保护重复操作为零、hold 可见,并在发起产品入口返回独立验收的结果。 | | Goal lifetime M2–M5/#5206 · 仅涉及的 P1 依赖 | 补齐所选 profile 剩余旧/warm writer 和 effect drain fence,再做其孤儿恢复/退役同名重建旅程 | 删除源码 writer 不排除已安装 1.x 二进制或缓存 executor。旧 A 不得写入、投递、扣额或结算同名 B;B 和无关工作仍推进。profile 的必要 owner 行通过前保持 activation hold;不重建已交付 codec/source-session 事务。 | | D2 · P1,与相关实现并行 | 同负载 File/SQLite 对照归因剩余完整命令/历史增长成本,对账正式容量和经过时间证据 | 分开 caller/bridge、共享 typed 事务和 provider 成本;保留当前 release 与迁移前对照、冻结失败/缺失轴、durability、恢复空间及十日/事务量要求。无关改动不重置有效经过时间证据,本计划不启动新 soak。 | diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index bd961ef337..42939aec8b 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -63,6 +63,14 @@ failed/untested cases retain their original revisions and applicability. Follow the [existing R5 delivery sequence](loopx-overall-roadmap-v0.md#post-6396-delivery-plan): +The [planned Agent transfer refinement](complete-state-recovery-v0.md#planned-agent-transfer) +sets the proposed product priority: move work and continue it in the destination, +with the necessary installed upgrade/admission companions below. Select TS +changes from actual breaks in that whole journey; do not advance a language or +capture-field series as a substitute. Source-available transfer and unavailable- +source disaster recovery keep distinct qualification. This does not authorize +either activation or claim that a transfer is implemented. + 1. Close T4/C1 and D1 adoption on pinned final-main source and normal installed CLI/App/affected Host entrypoints: complete data, fresh and stale proofs, original-operation retry, interruption, later writes and reversible migration. diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 9e416648be..8f8555a477 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -55,6 +55,12 @@ tree 相同;后续 main 同步与自动合并结果相同,并包含 #6597 沿用[既有 R5 交付顺序](loopx-overall-roadmap-v0.zh-CN.md#post-6396-delivery-plan): +[计划内 Agent 迁移细化](complete-state-recovery-v0.zh-CN.md#planned-agent-transfer) +提出产品优先级:把工作迁到目标并继续完成,下述安装升级/准入作为必要伴随。 +按完整旅程的实证断点选择 TS 改动,不用语言迁移或捕获字段系列代替用户结果。 +源端可用的迁移与原源不可用的灾难恢复分别资格化;这里不授予任何一方 activation, +也不声称迁移已经实现。 + 1. 在冻结的最终 main 源码和正常安装的 CLI/App/受影响 Host 上收尾 T4/C1、 D1 采用:完整数据、新旧 proof、原操作重试、中断、后写及可逆迁移。剩余 transport 按真实 caller 判断;native 进程内接管时删除废弃跨界,不新增逐字段 bridge。 diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index eb49f1de1c..0adf569235 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -264,6 +264,35 @@ an owner export, a consistent capture profile nor permission to reactivate. 包含更多私有原始字节;有界 migration 备份不变。这些仍是各自见证的历史文件, 不证明 owner export、一致性 profile 或再激活权限。 +## Moving an Agent to another environment + +The desired result is continued work with the Goal, full Todo/history, necessary +Agent context, materials and capability configuration available at the +destination. The current backup/inspection entry does not deliver that result: +it creates readable isolated evidence and grants no destination execution. +The [planned-transfer proposal](../architecture/rfcs/complete-state-recovery-v0.md#planned-agent-transfer) +defines one prepared source-to-destination journey and its required product, +identity, credential and failure-recovery boundaries. It is not an available +transfer command or a qualification claim. + +`migrate-state` is the retained legacy Goal Harness copy tool. Both its dry-run +and execution now check source and destination eligibility, including the old +runtime of an existing target registry. A canonical provider, promotion fence +or protected shadow source refuses instead of producing a successful copy +preview. This is a change to preview behavior; execution keeps its existing +locked recheck. A valid preview can become stale and grants no future write. +Do not use this command or an isolated `authority-archive restore` as a shortcut +to resume a modern Agent from a backup. + +中文:目标是让 Agent 带着目标、完整待办/历史、必要上下文、材料和能力配置,在 +目标环境继续工作。当前备份检查入口只提供隔离可读证据,尚未完成这个结果。 +[计划内迁移提案](../architecture/rfcs/complete-state-recovery-v0.zh-CN.md#planned-agent-transfer) +定义完整旅程及产品、身份、凭证和失败恢复边界,不是已经可用的迁移命令。 +`migrate-state` 仍只复制旧 Goal Harness 状态;现在 dry-run 和执行都会检查源端、 +目标端及目标 registry 原 runtime 的准入,canonical/fence/受保护 shadow 均拒绝。 +这是预览行为变更;执行时仍在原锁内重验,成功预览不保证以后可写。该旧工具和 +隔离 `authority-archive restore` 都不能作为备份后恢复现代 Agent 执行的捷径。 + ## Full-state recovery after cold import Keep the reviewed pre-import backup as well as a new full-state backup after diff --git a/loopx/state_migration.py b/loopx/state_migration.py index aba2f5148a..cbd9fc2a18 100644 --- a/loopx/state_migration.py +++ b/loopx/state_migration.py @@ -337,13 +337,13 @@ def migrate_legacy_state( target_payload.pop("registry_role", None) target_payload["goals"] = merge_goals(existing_goals, incoming_goals) + endpoints: dict[tuple[str, str], tuple[Path, str, dict[str, Any] | None, Path | None]] = {} + for source_goal, target_goal in selected_pairs: + old_id, new_id = str(source_goal["id"]), str(target_goal["id"]) + endpoints[(str(legacy_runtime_root.resolve()), old_id)] = (legacy_runtime_root, old_id, source_goal, resolve_goal_state(source_goal.get("repo"), source_goal.get("state_file"))) + endpoints[(str(target_runtime_root.resolve()), new_id)] = (target_runtime_root, new_id, find_registry_goal(existing_registry, new_id), resolve_goal_state(target_goal.get("repo"), target_goal.get("state_file"))) + ordered = [endpoints[key] for key in sorted(endpoints)] if execute: - endpoints: dict[tuple[str, str], tuple[Path, str, dict[str, Any] | None, Path | None]] = {} - for source_goal, target_goal in selected_pairs: - old_id, new_id = str(source_goal["id"]), str(target_goal["id"]) - endpoints[(str(legacy_runtime_root.resolve()), old_id)] = (legacy_runtime_root, old_id, source_goal, resolve_goal_state(source_goal.get("repo"), source_goal.get("state_file"))) - endpoints[(str(target_runtime_root.resolve()), new_id)] = (target_runtime_root, new_id, find_registry_goal(existing_registry, new_id), resolve_goal_state(target_goal.get("repo"), target_goal.get("state_file"))) - ordered = [endpoints[key] for key in sorted(endpoints)] for root, identity, _goal, _state in ordered: locks.enter_context(exclusive_cross_runtime_file_lock(legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity), operation="state_migration_todo")) for state_path in sorted({entry[3].resolve() for entry in ordered if entry[3] is not None}, key=str): @@ -355,12 +355,15 @@ def migrate_legacy_state( locks.enter_context(exclusive_cross_runtime_file_lock(target, operation="state_migration_leases")) for root, identity, _goal, _state in ordered: locks.enter_context(exclusive_cross_runtime_file_lock(task_lease_lock_path(runtime_root=root, goal_id=identity), operation="state_migration_leases")) - for root, identity, goal, _state in ordered: - require_legacy_state_replacement_allowed(runtime_root=root, goal_id=identity, goal=goal) - previous_root = resolve_runtime_root(existing_registry, None, registry_path=target_registry_path) - if previous_root != target_runtime_root: - for previous_goal in registry_goals(existing_registry): - require_legacy_state_replacement_allowed(runtime_root=previous_root, goal_id=str(previous_goal["id"]), goal=previous_goal) + # Preview uses the same read-only authority admission as execution. It + # must not advertise a legacy copy that cannot migrate the live source. + # Execution still rechecks these facts under its existing writer locks. + for root, identity, goal, _state in ordered: + require_legacy_state_replacement_allowed(runtime_root=root, goal_id=identity, goal=goal) + previous_root = resolve_runtime_root(existing_registry, None, registry_path=target_registry_path) + if previous_root != target_runtime_root: + for previous_goal in registry_goals(existing_registry): + require_legacy_state_replacement_allowed(runtime_root=previous_root, goal_id=str(previous_goal["id"]), goal=previous_goal) active_state_results = ( copy_active_state_files( diff --git a/tests/control_plane/test_state_migration_authority_shadow.py b/tests/control_plane/test_state_migration_authority_shadow.py index 9237fd3025..edbb6bde83 100644 --- a/tests/control_plane/test_state_migration_authority_shadow.py +++ b/tests/control_plane/test_state_migration_authority_shadow.py @@ -2,10 +2,13 @@ import json from pathlib import Path +import subprocess +import sys import pytest from loopx.state_migration import migrate_legacy_state, render_state_migration_markdown +from loopx.control_plane.coordination.legacy_writer_fence import LegacyCoordinationWriterFenced OLD_GOAL_ID = "legacy-goal" @@ -146,3 +149,92 @@ def test_migration_preserves_old_store_without_reseeding_retired_lineage(tmp_pat assert json.loads(lease.read_text())["goal_id"] == NEW_GOAL_ID else: assert not paths["target_registry"].exists() + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("endpoint", ["source", "target", "previous_runtime"]) +def test_preview_refuses_canonical_endpoints_without_copying( + tmp_path: Path, monkeypatch, provider: str, endpoint: str, +) -> None: + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + paths = _migration_fixture(tmp_path) + runtime, goal_id, state = paths["legacy_runtime"], OLD_GOAL_ID, paths["source_repo"] / "ACTIVE_GOAL_STATE.md" + if endpoint != "source": + runtime = paths["target_runtime"] if endpoint == "target" else tmp_path / "previous-runtime" + goal_id = NEW_GOAL_ID if endpoint == "target" else "retained-goal" + state = paths["target_repo"] / "RETAINED_STATE.md" + state.write_text("# Existing destination work\n") + paths["target_registry"].write_text(json.dumps({ + "schema_version": 1, "common_runtime_root": str(runtime), + "goals": [{"id": goal_id, "repo": str(paths["target_repo"]), "state_file": state.name}], + })) + initialize_canonical_authority(runtime, goal_id, {"goal_id": goal_id, "retained": True}, + state_path=state, provider=provider) + # The preview's validity must come from the real selected authority/fence, + # not from recognizing a particular registry spelling or a copied label. + def retained_files() -> dict[str, bytes]: + # The shared Effect transport may start a helper in the test's TMPDIR. + # Compare every source/target file, not that unrelated helper locator. + roots = [*paths.values(), tmp_path / "previous-runtime"] + return {str(path.relative_to(tmp_path)): path.read_bytes() + for root in roots for path in ([root] if root.is_file() else root.rglob("*")) + if path.is_file()} + + before = retained_files() + with pytest.raises(LegacyCoordinationWriterFenced): + _migrate(paths, execute=False) + assert retained_files() == before + assert not (paths["target_repo"] / "ACTIVE_GOAL_STATE.md").exists() + + +def test_execute_rechecks_authority_after_an_eligible_preview(tmp_path: Path, monkeypatch) -> None: + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + paths = _migration_fixture(tmp_path) + assert _migrate(paths, execute=False)["ok"] is True + state = paths["source_repo"] / "ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(paths["legacy_runtime"], OLD_GOAL_ID, + {"goal_id": OLD_GOAL_ID, "retained": True}, state_path=state) + before = state.read_bytes() + with pytest.raises(LegacyCoordinationWriterFenced): + _migrate(paths, execute=True) + assert state.read_bytes() == before + assert not (paths["target_repo"] / state.name).exists() + assert not paths["target_registry"].exists() + assert not (paths["target_runtime"] / "authority").exists() + + +@pytest.mark.parametrize("provider", [None, "file", "sqlite"]) +def test_public_migration_preview_reports_actual_source_eligibility( + tmp_path: Path, monkeypatch, provider: str | None, +) -> None: + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + paths = _migration_fixture(tmp_path) + if provider is not None: + initialize_canonical_authority(paths["legacy_runtime"], OLD_GOAL_ID, + {"goal_id": OLD_GOAL_ID, "retained": True}, + state_path=paths["source_repo"] / "ACTIVE_GOAL_STATE.md", provider=provider) + command = [sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--registry", str(paths["target_registry"]), "migrate-state", + "--legacy-registry", str(paths["legacy_registry"]), + "--legacy-runtime-root", str(paths["legacy_runtime"]), + "--target-runtime-root", str(paths["target_runtime"]), "--goal-id", OLD_GOAL_ID, + "--goal-id-map", f"{OLD_GOAL_ID}={NEW_GOAL_ID}", + "--path-map", f"{paths['source_repo']}={paths['target_repo']}", + "--copy-active-state", "--copy-runtime"] + child = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=60) + result = json.loads(child.stdout) + assert result["dry_run"] is True + assert result["ok"] is (provider is None), result + assert child.returncode == (0 if provider is None else 1) + if provider is not None: + assert result["write_check"]["status"] == "blocked" + assert result["error_code"] == result["write_check"]["reason_code"] + assert not paths["target_registry"].exists() + assert not (paths["target_repo"] / "ACTIVE_GOAL_STATE.md").exists() + assert not paths["target_runtime"].exists()