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")