diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md
index dfb58a5273..bb92a207f1 100644
--- a/docs/architecture/rfcs/complete-state-recovery-v0.md
+++ b/docs/architecture/rfcs/complete-state-recovery-v0.md
@@ -64,70 +64,44 @@ 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 |
+
+
+### 2.0 in-place upgrade: existing primitives and an Agent prompt
+
+Migration in this delivery means **upgrade existing LoopX 1.x work to 2.0 in
+place**. Keep the same machine, project, registry/runtime routes and Goal/Agent
+identities. The [copyable upgrade prompt](../../guides/agent-upgrade.md) lets the
+user's Agent compose existing operations. Moving work to another environment,
+transferring sessions and creating a dedicated migration executor or UI are
+outside this delivery.
+
+The Agent identifies the installation owner and each Goal's actual provider,
+format and ownership policy, stops affected old writers through their owners,
+settles pending operations and authorized leases, and makes a private backup.
+It updates through the installation owner and uses a format-compatible verifier
+before changing Goal data. It applies only the required changes and continues original work
+through its current admission. Existing valid configuration remains selected;
+new-Goal defaults do not migrate an existing Goal.
+
+| Observed upgrade need | Existing owner / entry |
| --- | --- |
-| `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.
+| Package/runtime and Host material | Existing installation owner and `update check/plan/apply`; preserve state routes and verify active layers. |
+| Old canonical authority format | `authority-archive upgrade`; back up, verify and upgrade the existing store without switching provider. |
+| Unpromoted Markdown authority | `coordination-shadow inspect-source/prepare-import/apply-import`; import complete records into the reviewed provider for the same registered Goal. |
+| Historical `legacy` ownership policy | `handoff-mode plan-migration/migrate`; preserve assignments and settled history while explicitly selecting `soft_claim` or `hard_lease`. |
+
+Qualify an ordinary 1.4.2 installation upgraded to the pinned 2.0 candidate in
+the same isolated environment and paths. Read back full active/archive records,
+relationships, configuration and original receipts; continue and verify one
+useful original task step. Cover the relevant refusals and same-operation
+recovery without duplicating effects or overwriting later writes. Polish only
+reproduced gaps in the existing owners. Binary rollback is not data rollback;
+retain the owning backup/recovery and post-upgrade-write boundaries.
+
+M2/M3 disaster recovery below remains separate. An in-place upgrade neither
+requires a new Goal lifetime nor qualifies cross-machine transfer, unavailable-
+source recovery, all Host profiles or D2/D3. This proposal authorizes no live
+upgrade, release, credential transfer or paid execution.
### First supported user outcome
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 fc54c7614c..af5ad3f10a 100644
--- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
+++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
@@ -49,55 +49,35 @@ held,未知状态不改称可恢复,执行权限始终为 false。限制与
继续由 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 / 入口 | 复用及剩余工作 |
+### 2.0 原地迁移式升级:已有原子能力与 Agent prompt
+
+本次迁移指**现有 LoopX 1.x 工作原地升级到 2.0**。保持同一机器、项目、
+registry/runtime 路由及 Goal/Agent 身份,由用户的 Agent 配合[可复制升级 prompt](../../guides/agent-upgrade.md)
+组合现有操作。本次不交付跨环境搬迁、会话转移、专用迁移 executor 或 UI。
+
+Agent 识别安装归属及各 Goal 的真实 provider、格式和所有权策略,通过原 owner
+停止受影响旧写方、对账未完成操作并结清获准处理的租约,先私有备份。沿安装 owner
+更新,并在改变 Goal 数据前用兼容工具校验备份。只执行必要变化,然后按当前准入
+继续原工作。有效的已有配置保持选定;修改
+新 Goal 默认值不等于升级现有 Goal。
+
+| 实际升级需要 | 既有 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 恢复合格。本提案不新增激活、
-凭证转移、付费实验或发布授权。
+| 安装包/runtime 与 Host 材料 | 既有安装 owner 和 `update check/plan/apply`;保持状态路由并验证实际激活层。 |
+| 旧 canonical authority 格式 | `authority-archive upgrade`;对现有 store 备份、校验和格式升级,不切换 provider。 |
+| 尚用 Markdown authority 的 Goal | `coordination-shadow inspect-source/prepare-import/apply-import`;为同一已注册 Goal 向审核的 provider 导入完整记录。 |
+| 历史 `legacy` 所有权策略 | `handoff-mode plan-migration/migrate`;保留归属与已结算历史,明确选择 `soft_claim` 或 `hard_lease`。 |
+
+用普通 1.4.2 安装在同一隔离环境、同一路径升级到固定 2.0 候选,读回完整活动/
+归档记录、关系、配置和原 receipts,继续原任务有用的一步并验证结果。覆盖相关拒绝
+及原 operation 恢复,不重复副作用、不覆盖后写。只打磨既有 owner 的可复现断点。
+回退二进制不等于回退数据,保留对应备份/恢复与升级后新写的边界。
+
+下文 M2/M3 灾难恢复独立保留。原地升级不要求新 Goal lifetime,也不证明跨机
+搬迁、原源不可用恢复、所有 Host 或 D2/D3 合格。本提案不授权活跃升级、发布、
+凭证转移或付费执行。
### 首个支持的用户结果
diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
index 114a7b7e75..06848436f1 100644
--- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
+++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
@@ -1012,7 +1012,7 @@ The dated source checkpoints below must not regenerate completed writer work.
| Existing milestone / priority | Next independently reviewable outcome | Acceptance and continuation |
| --- | --- | --- |
-| 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. |
+| Agent-assisted 2.0 in-place upgrade · P0 bounded refinement | Upgrade existing 1.x work in the same installation/project using existing installation, backup, authority-format/import and ownership-policy primitives, plus a copyable Agent prompt | Follow the [in-place upgrade route](complete-state-recovery-v0.md#in-place-major-upgrade). Verify preserved routes, complete active/archive records, configuration and original receipts, then useful original work; cover the relevant refusal and original-operation recovery. Keep valid explicit providers/policies. No cross-machine transfer, session migration or dedicated executor/UI is required. M2/M3 disaster recovery, D2 and release authority remain separate. |
| 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. |
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 094ac843f4..3c3abd1b00 100644
--- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
+++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
@@ -704,7 +704,7 @@ writer 工作。
| 既有里程碑/优先级 | 下一独立可评审结果 | 验收与后续 |
| --- | --- | --- |
-| 计划内 Agent 迁移 · P0 产品细化提案 | 用户准备可联系的源端,把一个有真实内容的 Goal 及其负责 Agent 搬到明确的正常安装本地 profile,并从发起产品入口继续未完成工作 | 按[已准备的迁移旅程](complete-state-recovery-v0.zh-CN.md#planned-agent-transfer)交付必要上下文/材料、目标准入、过期源排除及原操作失败恢复。所选 profile 的实际依赖和可携产物保护须合格;不等待全部云/平台 profile,不把备份检查、TS 删行或 D2 默认资格当用户结果。这是下一交付提案,不代表迁移已实现或授予激活。 |
+| Agent 协助 2.0 原地升级 · P0 有界打磨 | 在同一安装/项目中,复用已有安装更新、备份、authority 格式升级/导入和所有权策略原子能力,配合可复制 prompt 升级现有 1.x 工作 | 按[原地升级路径](complete-state-recovery-v0.zh-CN.md#in-place-major-upgrade)验证原路由、完整活动/归档记录、配置和原 receipts 保留,并继续原工作;覆盖相关拒绝与原 operation 恢复,保持有效显式 provider/policy。不要求跨机搬迁、会话转移或专用 executor/UI;M2/M3 灾难恢复、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 事务。 |
diff --git a/docs/guides/agent-upgrade.md b/docs/guides/agent-upgrade.md
new file mode 100644
index 0000000000..21f021d897
--- /dev/null
+++ b/docs/guides/agent-upgrade.md
@@ -0,0 +1,96 @@
+# Agent-assisted upgrade to LoopX 2.0
+
+This migration upgrades an existing LoopX installation and its data **in place**.
+Keep the same machine, project, registry, runtime route and Goal identities.
+The user's Agent composes the existing upgrade primitives; no transfer service,
+new conversation or migration-specific UI is required.
+
+Use an explicitly selected 2.0 release or candidate. A source checkout or
+prerelease is not a released upgrade channel. Preview the affected scope and
+keep a verified private backup before changing data.
+
+## Give this prompt to your Agent
+
+```text
+Upgrade my existing LoopX 1.x installation and work in place to . Keep the existing project, registry/runtime routes,
+Goals, Agents, complete active and archived Tasks, relationships, materials,
+configuration and original operation receipts. Continue my unfinished work
+after the upgrade. Work within my existing authorization; ask only for a
+necessary choice or permission you cannot resolve yourself.
+
+Inspect the active installation owner/version and each Goal's real authority,
+format and ownership policy. Stop affected old writers through their existing
+owners, reconcile pending operations, release only authorized leases, and make
+a private backup. Upgrade through the existing installation owner, retaining
+the rollback copy. Before changing Goal data, verify the backup with a
+format-compatible verifier.
+
+Choose only the data operations actually needed: upgrade an old canonical
+format; import an unpromoted Markdown Goal into its reviewed canonical provider;
+or migrate a historical legacy ownership policy to soft_claim/hard_lease. Keep
+valid explicit providers and policies. Preview and verify each existing owner's
+backup/plan before applying it. Do not copy the project elsewhere, create an
+empty replacement Goal, remap working paths, hand-edit registry/SQL, or invent
+execution authority to bypass a refusal.
+
+Read back the same Goals and full Task/configuration data, restart affected
+processes on the new version, and continue one useful step of the original
+task using its current admission and claim/lease rules. Verify its actual
+result. After interruption, recover the original operation and digest; do not
+repeat an effect or overwrite later writes. Report the selected versions,
+preserved routes/data, verified result, remaining holds and rollback boundary.
+If an upgrade primitive fails, preserve the source and name that specific gap.
+```
+
+## Reuse the owner for the observed state
+
+Inspect the actual installation's `--help` before executing commands. These
+operations have different owners; a successful package update does not prove
+that existing Goal data have been upgraded.
+
+| Observed state | Existing entry | Required readback |
+| --- | --- | --- |
+| Installation or Host material needs updating | `update check/plan/apply`, or the package-manager command reported by the plan | Correct package/runtime and refreshed Host material; retain existing state routes. |
+| Existing canonical authority has an old format | `authority-archive upgrade`, then its reviewed `--execute` | Verified backup, current format and preserved history/receipts; no provider switch. |
+| Existing Goal still uses Markdown authority | `coordination-shadow inspect-source`, `prepare-import`, `apply-import` | Full source records and settled history imported into the reviewed provider; original paths remain. Recover the same import with `recover-import`. |
+| Canonical Goal retains historical `legacy` policy | `handoff-mode plan-migration`, `handoff-mode migrate` | Verified backup, preserved assignments and an explicit supported policy. Recover the same plan after a lost response. |
+
+An expired lease is not proof that its writer stopped. `--writers-stopped`
+attests a fact already established through the owning Host; it does not stop
+one. An already current canonical Goal needs no import. Setting new-Goal
+defaults does not upgrade existing Goals. Storage and ownership policy remain
+independent choices.
+
+Reinstalling an old binary does not reverse a data upgrade or clear writer
+fences. After new writes, use the owning recovery/export or reverse-migration
+operation that preserves them; never unpack an old backup over live state.
+
+See [installation and activation](installing-loopx.md#verify-the-active-layers),
+[authority formats](../reference/authority-archive.md),
+[existing Goal import](../reference/local-authority-provider-selection.md),
+[ownership policy](../reference/handoff-mode.md) and
+[backup verification](../reference/configuration-backup.md).
+
+## 可直接交给 Agent 的中文提示
+
+```text
+请将我现有的 LoopX 1.x 原地迁移升级到 <指定的 2.0 版本或候选>,升级后继续原来
+未完成的工作。保持同一机器、项目、registry/runtime 路由和 Goal/Agent 身份,保留
+完整活动与归档任务、关系、材料、配置及原 operation receipts。
+
+在现有授权内自行识别安装归属、版本和各 Goal 的真实 authority/格式/所有权策略;
+通过原 owner 停止受影响的旧写方、对账未完成操作并结清获准处理的租约,先私有备份,
+再通过安装 owner 更新并保留回退副本;执行数据迁移前用兼容版本校验备份。
+只询问无法自行解决的必要选择或权限。
+
+按实际状态复用已有原子入口:旧 canonical 格式升级、旧 Markdown Goal 原地导入
+canonical provider、历史 legacy policy 审核迁到 soft_claim/hard_lease。有效的显式
+provider/policy 保持不变,逐项预览并核对备份与计划后执行。不搬项目、不改工作路径、
+不新建空 Goal 冒充升级,不手改 registry/SQL 或伪造执行权绕过拒绝。
+
+读回原 Goal、完整任务和有效配置,重启需要激活新版的进程,按当前准入和 claim/lease
+规则继续原任务的一步并检查真实结果。中断后用原 operation/digest 恢复,不重复副作用,
+不以旧备份覆盖后写。最后报告版本、保留的数据与路由、验证结果、剩余阻塞和回退边界。
+原子升级入口有问题时保留原数据,只指出并修补具体缺口,不扩建迁移系统。
+```
diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md
index b8c8310a8e..b47ff006bb 100644
--- a/docs/reference/configuration-backup.md
+++ b/docs/reference/configuration-backup.md
@@ -170,7 +170,11 @@ Inspect the private external manifest with
isolated restore, independently hash each listed regular member and compare its
byte count. The list excludes the self-containing `manifest.json`, directories
and tar links; retain the existing whole-archive checksum for their integrity
-and inspect link destinations separately. Earlier backups may lack this list;
+and inspect link destinations separately.
+The inert verifier accepts producer-generated hard links only to an earlier
+regular member inside the archive. It recreates that link after verified file
+extraction with private permissions. Missing, forward, chained, symlink and
+escaping targets remain refused; links never resolve against the live source. Earlier backups may lack this list;
its absence does not grant migration qualification or erase their recovery
support. A failed or short source read leaves an earlier backup intact.
@@ -475,8 +479,9 @@ and 16 MiB total extended metadata. Paths have at most 64 components, 1,024
characters and 255 UTF-8 bytes per component; JSON nesting is bounded at 64.
Owner inspection permits 128 Goals, 128 SQLite files and 20,000 committed
transactions per Goal. Overflow refuses publication rather than truncating a
-required inventory. Internal symlinks remain inert; escaping/cyclic links,
-hard links, sparse or special files and normalized name collisions reject.
+required inventory. Internal symlinks and hard links to earlier regular members
+remain inert; escaping/cyclic links, other hard-link targets, sparse or special
+files and normalized name collisions reject.
Archive parsing never loads restored code. Database/domain inspection runs in a
disposable verifier with a 90-second deadline and a 512 MiB V8 heap ceiling.
@@ -640,3 +645,11 @@ Native Windows tests cover the configuration-only CLI export/verify/restore and
local HTTP checkpoint with UTF-8 values. The packaged browser journey, full
state backup, provider promotion, a destination machine and long-duration SQLite
operation remain separate qualifications.
+
+## Agent-assisted 2.0 in-place upgrade
+
+Use the [copyable upgrade prompt](../guides/agent-upgrade.md) to let your Agent
+compose the existing installation and data-upgrade primitives in the same
+project and state routes. Verify the private backup before mutation, preserve
+full Tasks/configuration/receipts, then verify useful original work. Backup
+verification alone does not upgrade a Goal or authorize a data rollback.
diff --git a/loopx/control_plane/state_recovery_io.py b/loopx/control_plane/state_recovery_io.py
index d6344ef4d9..d2b858c435 100644
--- a/loopx/control_plane/state_recovery_io.py
+++ b/loopx/control_plane/state_recovery_io.py
@@ -232,6 +232,40 @@ def _check_links(links: list[tuple[str, str]]) -> None:
pending = list(PurePosixPath(by_name[key]).parts) + pending
+def _copy_regular_member(
+ archive: tarfile.TarFile, member: tarfile.TarInfo, destination: Path | None, *, keep_data: bool,
+) -> tuple[str, int, bytes, bytes]:
+ """Witness the bounded stream used by both preview and private extraction."""
+ source = archive.extractfile(member)
+ if source is None:
+ raise StateRecoveryError("archive_member_unreadable")
+ data, prefix = bytearray(), bytearray()
+ digest, size = hashlib.sha256(), 0
+ target = None
+ try:
+ if destination is not None:
+ target = open(destination, "xb")
+ os.chmod(destination, 0o600)
+ while chunk := source.read(_CHUNK):
+ size += len(chunk)
+ _require(size <= member.size, "archive_member_size_mismatch")
+ digest.update(chunk)
+ prefix.extend(chunk[:max(0, 100 - len(prefix))])
+ if keep_data:
+ data.extend(chunk)
+ if target is not None:
+ target.write(chunk)
+ _require(size == member.size, "archive_member_size_mismatch")
+ if target is not None:
+ target.flush()
+ os.fsync(target.fileno())
+ finally:
+ source.close()
+ if target is not None:
+ target.close()
+ return digest.hexdigest(), size, bytes(prefix), bytes(data)
+
+
def _scan(handle: BinaryIO, archive_size: int, output: Path | None = None) -> dict[str, Any]:
handle.seek(0)
stream = _BoundedGzip(handle, archive_size)
@@ -239,6 +273,7 @@ def _scan(handle: BinaryIO, archive_size: int, output: Path | None = None) -> di
folded: dict[str, str] = {}
ancestors: set[str] = set()
links: list[tuple[str, str]] = []
+ hardlinks: list[tuple[str, str]] = []
files, sqlite_members, standalone = [], [], []
embedded: dict[str, Any] | None = None
logical = 0
@@ -250,7 +285,8 @@ def _scan(handle: BinaryIO, archive_size: int, output: Path | None = None) -> di
_require(name not in names and key not in folded, "archive_name_collision")
_require(len(names) < MAX_MEMBERS, "recovery_capacity_exceeded")
_require(member.sparse is None, "archive_sparse_unsupported")
- kind = "file" if member.isfile() else "directory" if member.isdir() else "symlink" if member.issym() else None
+ kind = ("file" if member.isfile() else "directory" if member.isdir()
+ else "symlink" if member.issym() else "hardlink" if member.islnk() else None)
_require(kind is not None, "archive_member_type_unsupported")
names[name], folded[key] = str(kind), name
for parent in PurePosixPath(name).parents:
@@ -260,6 +296,19 @@ def _scan(handle: BinaryIO, archive_size: int, output: Path | None = None) -> di
"archive_parent_not_directory")
if kind != "directory":
_require(key not in ancestors, "archive_parent_not_directory")
+ if kind == "hardlink":
+ # Our producer deduplicates repeated inodes against an
+ # earlier regular member. Never resolve via a live path,
+ # symlink, forward reference or another hard link.
+ target = _member_name(member.linkname)
+ _require(names.get(target) == "file", "archive_member_type_unsupported")
+ _require(member.size == 0, "archive_member_size_mismatch")
+ hardlinks.append((name, target))
+ if target in sqlite_members:
+ sqlite_members.append(name)
+ if target in standalone:
+ standalone.append(name)
+ continue
if kind == "symlink":
target = member.linkname
_require(bool(target) and len(target) <= 1024 and "\x00" not in target
@@ -278,37 +327,13 @@ def _scan(handle: BinaryIO, archive_size: int, output: Path | None = None) -> di
"recovery_capacity_exceeded")
if name == "manifest.json":
_require(member.size <= MAX_MANIFEST_BYTES, "archive_metadata_too_large")
- source = archive.extractfile(member)
- if source is None:
- raise StateRecoveryError("archive_member_unreadable")
- data, prefix = bytearray(), bytearray()
- digest, size = hashlib.sha256(), 0
- destination = None
- try:
- if output is not None:
- destination = open(output / name, "xb")
- os.chmod(output / name, 0o600)
- while chunk := source.read(_CHUNK):
- size += len(chunk)
- _require(size <= member.size, "archive_member_size_mismatch")
- digest.update(chunk)
- prefix.extend(chunk[:max(0, 100 - len(prefix))])
- if name == "manifest.json":
- data.extend(chunk)
- if destination is not None:
- destination.write(chunk)
- _require(size == member.size, "archive_member_size_mismatch")
- if destination is not None:
- destination.flush()
- os.fsync(destination.fileno())
- finally:
- source.close()
- if destination is not None:
- destination.close()
+ digest, size, prefix, data = _copy_regular_member(
+ archive, member, output / name if output is not None else None,
+ keep_data=name == "manifest.json")
if name == "manifest.json":
- embedded = _json(bytes(data))
+ embedded = _json(data)
else:
- files.append({"archive_path": name, "sha256": digest.hexdigest(), "size_bytes": size})
+ files.append({"archive_path": name, "sha256": digest, "size_bytes": size})
if prefix[:16] == b"SQLite format 3\0":
sqlite_members.append(name)
if prefix[18:20] == b"\x01\x01":
@@ -330,6 +355,9 @@ def _scan(handle: BinaryIO, archive_size: int, output: Path | None = None) -> di
_check_links(links)
# Links are materialized last and no member may live beneath a link.
if output is not None:
+ for name, target in hardlinks:
+ _directories(output, PurePosixPath(name).parts[:-1])
+ os.link(output / target, output / name, follow_symlinks=False)
for name, target in links:
_directories(output, PurePosixPath(name).parts[:-1])
os.symlink(target, output / name)
diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py
index b21a8f6fdb..bd813d52b1 100644
--- a/tests/test_state_recovery.py
+++ b/tests/test_state_recovery.py
@@ -291,6 +291,53 @@ def test_legacy_archive_without_source_bindings_keeps_original_runtime_readback(
assert audit["status"] == "held" and audit["execution_authority_granted"] is False
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+def test_real_backup_restores_hardlinked_materials_without_the_source(tmp_path, provider):
+ registry, runtime, state = promoted_create_fixture(tmp_path, provider=provider)
+ project = state.parents[3]
+ project_registry = project / ".loopx/registry.json"
+ project_registry.parent.mkdir()
+ registry.rename(project_registry)
+ registry = project_registry
+ material = runtime / "required-material.txt"
+ material.write_text("Material needed to continue the unfinished task.\n")
+ alias = runtime / "material-alias.txt"
+ os.link(material, alias)
+ code, added = _cli("--registry", registry, "--runtime-root", runtime, "todo", "add",
+ "--goal-id", "goal-a", "--role", "agent", "--claimed-by", "agent-a",
+ "--operation-id", "hardlinked-material", "--text", "Continue using the retained material")
+ assert code == 0, added
+ result = execute_state_backup_plan(build_state_backup_plan(
+ project=project, registry_path=registry, runtime_root=runtime,
+ output_dir=tmp_path / "backups", include_registry_projects=False,
+ include_automations=False, include_skills=False))
+ archive, manifest = Path(result["archive_path"]), Path(result["manifest_path"])
+ with tarfile.open(archive) as saved:
+ hardlinks = [member for member in saved if member.islnk()]
+ assert any(member.name == "runtime-root/required-material.txt" for member in hardlinks)
+ expected = material.read_bytes()
+ runtime.rename(tmp_path / "unavailable-runtime")
+ project.rename(tmp_path / "unavailable-project")
+ destination = tmp_path / "inert"
+ arguments = ("backup-state", "verify", "--archive", archive, "--manifest", manifest,
+ "--destination", destination)
+ code, preview = _cli(*arguments)
+ assert code == 0, preview
+ assert preview["written"] is False and not destination.exists()
+ code, audit = _cli(*arguments, "--execute")
+ assert code == 0, audit
+ for name in ("required-material.txt", "material-alias.txt"):
+ restored = destination / "data/runtime-root" / name
+ assert restored.read_bytes() == expected
+ assert restored.stat().st_mode & 0o077 == 0
+ assert os.path.samefile(destination / "data/runtime-root/required-material.txt",
+ destination / "data/runtime-root/material-alias.txt")
+ [goal] = [row for row in audit["components"] if row["owner_kind"] == "coordination_authority"]
+ assert goal["state"] == "readable" and goal["facts"]["todo_count"] == 1
+ assert audit["execution_authority_granted"] is False
+ assert read_state_recovery_audit(destination) == audit
+
+
@pytest.mark.parametrize("provider", ["file", "sqlite"])
def test_real_backup_recovers_readable_authority_after_original_sources_disappear(tmp_path, provider):
archive, manifest = _backup(tmp_path, provider)
@@ -621,7 +668,7 @@ def test_recomputed_archive_digest_does_not_hide_member_corruption(tmp_path):
("runtime-root/RETAINED.txt", tarfile.REGTYPE, "", "archive_name_collision"),
("runtime-root/retained.txt", tarfile.REGTYPE, "", "archive_name_collision"),
("fifo", tarfile.FIFOTYPE, "", "archive_member_type_unsupported"),
- ("hardlink", tarfile.LNKTYPE, "runtime-root/retained.txt", "archive_member_type_unsupported"),
+ ("hardlink", tarfile.LNKTYPE, "missing-target", "archive_member_type_unsupported"),
("outside", tarfile.SYMTYPE, "../escape", "archive_link_escapes"),
("runtime-root", tarfile.SYMTYPE, "project", "archive_name_collision"),
])
@@ -636,6 +683,30 @@ def test_archive_paths_and_member_types_fail_before_destination_creation(tmp_pat
assert not (tmp_path.parent / "escape").exists()
+@pytest.mark.parametrize("target,reason", [
+ ("../escape", "archive_path_invalid"),
+ ("/absolute", "archive_path_invalid"),
+ ("safe-link", "archive_member_type_unsupported"),
+ ("prior-hardlink", "archive_member_type_unsupported"),
+ ("hardlink", "archive_member_type_unsupported"),
+])
+def test_hardlink_targets_must_be_prior_regular_archive_members(tmp_path, target, reason):
+ archive, manifest = _backup(tmp_path)
+ symlink = tarfile.TarInfo("safe-link")
+ symlink.type, symlink.linkname = tarfile.SYMTYPE, "runtime-root/retained.txt"
+ _rewrite(archive, manifest, symlink)
+ prior = tarfile.TarInfo("prior-hardlink")
+ prior.type, prior.linkname = tarfile.LNKTYPE, "runtime-root/retained.txt"
+ _rewrite(archive, manifest, prior)
+ member = tarfile.TarInfo("hardlink")
+ member.type, member.linkname = tarfile.LNKTYPE, target
+ _rewrite(archive, manifest, member)
+ with pytest.raises(StateRecoveryError, match=reason):
+ verify_state_backup(archive=archive, manifest=manifest, destination=tmp_path / "inert", execute=True)
+ assert not (tmp_path / "inert").exists()
+ assert not list(tmp_path.glob(".loopx-recovery-*"))
+
+
def test_large_pax_metadata_is_refused_before_tarfile_decodes_its_body(tmp_path):
archive, manifest = _backup(tmp_path)
member = tarfile.TarInfo("extended-metadata")