diff --git a/docs/architecture/rfcs/cross-session-memory-substrate-v0.md b/docs/architecture/rfcs/cross-session-memory-substrate-v0.md index 8298fa68f2..5fdd1b8e4f 100644 --- a/docs/architecture/rfcs/cross-session-memory-substrate-v0.md +++ b/docs/architecture/rfcs/cross-session-memory-substrate-v0.md @@ -4,7 +4,7 @@ Stage A shipped in [#4094](https://github.com/huangruiteng/loopx/pull/4094), merge `2ebd921ee989f7c696a7214ba1176d3bd5de6fb3`. The historical filename does not imply that a generic memory substrate shipped. The [capable manager and semantic handoff RFC](capable-manager-semantic-handoff-v0.md#513-integrate-shipped-explicit-continuation-4094) includes this implementation in its M2/M3 refactor scope. That successor remains proposed; this document remains the shipped CLI compatibility and authority reference until its replacement qualifies. -Reuse rich/legacy context and the existing note validator/claim transfer boundary. Receiver acceptance of a suggestion is distinct from `handoff adopt` ownership mutation. Stage A's note is replaceable current Todo state, not a private immutable history. The successor maps authorized context to a recoverable brief, references current work, and supplies general assessment/result/automatic-return relations without adding a memory ledger or copying claim authority. CLI `prepare/inspect/adopt` remains usable during migration; frontend/Lark and automatic host continuation must be qualified separately. Same-host, registered-agent, lease-free restrictions continue to apply to this adapter, not every general request. See successor §5.13 for mappings, migration conditions and retained negative cases. +Reuse rich/legacy context and the existing note validator/claim transfer boundary. Receiver acceptance of a suggestion is distinct from `handoff adopt` ownership mutation. Stage A's note is replaceable current Todo state, not a private immutable history. The successor maps authorized context to a recoverable brief, references current work, and supplies general assessment/result/automatic-return relations without adding a memory ledger or copying claim authority. CLI `prepare/inspect/adopt` remains usable during migration; frontend/Lark and automatic host continuation must be qualified separately. Same-host and registered-agent restrictions continue to apply to this adapter, not every general request. Leased execution uses the explicit transfer path below. See successor §5.13 for mappings, migration conditions and retained negative cases. The successor [§5.7](capable-manager-semantic-handoff-v0.md#57-session-and-product-continuity) now distinguishes same-session resume, same-Agent session replacement and cross-Agent takeover. Only the last may require this adapter’s ownership mutation. Automatic brief capture, source-loss recovery, executable-session fencing and original-route result return are explicit future integration work; optional Obelisk recall supplies missing historical evidence, not a replacement transfer grant. @@ -32,8 +32,7 @@ require a different product layer beyond this control-plane primitive. ## Ownership and placement The existing Todo coordination boundary owns current execution state, stable -Todo IDs, revision checks and claim/lease decisions. The built-in local -`file_v0` authority supplies persistence; no new capability, provider, database, +Todo IDs, revision checks and claim/lease decisions. The selected canonical File/SQLite authority supplies persistence; no new capability, provider, database, index, discovery API, recovery service or ownership protocol is introduced. The CLI is a host adapter to that TypeScript boundary. @@ -44,11 +43,12 @@ nor grants permission to index a workspace or read source-reference bodies. ## First usable path -Prerequisites: an **already explicitly promoted local file authority**, an open, -active agent Todo claimed by a registered agent, and no lease on that Todo. -The existing metadata writer cannot prove lease-bearing updates, so Stage A -rejects hard-lease goals and lease-bearing Todos. It never promotes authority, -changes handoff mode, releases another owner's work or falls back to Markdown. +Prerequisites: an **already explicitly promoted canonical authority** and an +open, active Agent Todo claimed by a registered Agent. Lease-free adoption +retains the existing claim transaction. Hard-lease work requires the exact +current execution proof; ownership moves only through the existing atomic +`task-lease transfer --transfer-claim` command. This adapter never promotes a +Goal, changes its handoff mode or falls back to Markdown. The user explicitly hands a Todo from one session to another session of a **different registered agent on the same host**. The source writes a revision- @@ -105,6 +105,51 @@ ID, invokes the existing claim transaction and reads back current authority. A same-owner claim may correctly be a no-op; it does not create a Todo or manufacture a new lease. +## Leased execution continuation + +Prepare while the sender still owns the claim and lease. Pass the current +`--task-lease-idempotency-key` and `--task-lease-expected-version` pair to prepare. +Use actual readback versions; the example assumes source version 3. + +```bash +loopx handoff prepare --goal-id demo --todo-id todo_a \ + --agent-id agent-a --session-id source-session --operation-id prepare-context \ + --expected-revision "$SOURCE_REVISION" --from-context ./handoff-context.json \ + --task-lease-idempotency-key execution-a --task-lease-expected-version 3 +loopx task-lease transfer --goal-id demo --todo-id todo_a --owner agent-a \ + --idempotency-key execution-a --expected-version 3 --new-owner agent-b \ + --new-idempotency-key execution-b --ttl-seconds 600 --transfer-claim +loopx handoff inspect --goal-id demo --todo-id todo_a \ + --agent-id agent-b --session-id target-session --workspace . \ + --task-lease-idempotency-key execution-b --task-lease-expected-version 4 +loopx handoff adopt --goal-id demo --todo-id todo_a \ + --agent-id agent-b --session-id target-session --operation-id accept-context \ + --expected-revision "$TARGET_REVISION" --workspace . \ + --task-lease-idempotency-key execution-b --task-lease-expected-version 4 +``` + +Inspect remains read-only without proof, but cannot report `can_adopt=true` +for leased work without current execution authority. Transfer carries a note +forward only when it was valid before the authorized claim change; it updates +that note's owner-bound fingerprint in the same CAS. Changed work requirements +and previously stale notes remain stale. Arbitrary metadata edits cannot +rebind a note. An already-committed historical transfer is replayed unchanged. + +Leased adopt seals a context receipt bound to Agent, session, note, revision and +lease proof. It does not transfer, acquire or renew a lease. Its `adoption` +result has `changed=false`; `current_authority_verified` additionally requires +fresh authority readback. The old lease-free result retains its `claim` field. +Historical receipts never override a released/expired lease, a different owner, +changed requirements or a Goal acceptance hold. Session IDs remain provenance, +not host authentication or grants to execute external tools. + +Prepare/adopt deliver current canonical Todo state to the existing Markdown +projection. Display failure returns `projection_delivery=pending` with +`retry_business_mutation=false`; repair the local display inputs and retry the +same operation or use `todo project-markdown`. Inspect never writes a display. +The feature does not create frontend/Lark controls or launch a target session; +it is an explicit CLI workflow, not automatic manager-to-worker delegation. + ## Rich handoff context The `--from-context` flag accepts a JSON file with structured handoff context: @@ -176,7 +221,7 @@ The focused test runs separate source and target Python CLI processes against a disposable real file authority, plus restart, lost acknowledgment, failed write, missing artifact, stale revision, changed owner, completed Todo, rich context, and legacy backward-compat cases. Existing claim/update suites cover -default behavior and lease rejection. +default behavior and rejection of missing, stale or foreign execution proofs. ```sh node --experimental-strip-types --test tests/control_plane_ts/todo_continuation.test.ts diff --git a/docs/architecture/rfcs/cross-session-memory-substrate-v0.zh-CN.md b/docs/architecture/rfcs/cross-session-memory-substrate-v0.zh-CN.md index 1103bab86c..dbff431b12 100644 --- a/docs/architecture/rfcs/cross-session-memory-substrate-v0.zh-CN.md +++ b/docs/architecture/rfcs/cross-session-memory-substrate-v0.zh-CN.md @@ -4,7 +4,7 @@ Stage A 已在 [#4094](https://github.com/huangruiteng/loopx/pull/4094) 以 `2ebd921ee989f7c696a7214ba1176d3bd5de6fb3` 合并。历史文件名不表示通用 memory substrate 已交付。[强能力管家与语义交接 RFC](capable-manager-semantic-handoff-v0.zh-CN.md#513-整合已交付的显式接续4094) 将本实现纳入 M2/M3 重构范围。后继方案仍是提案;替代路径验收前,本文继续作为已交付 CLI 兼容性及权威边界参考。 -复用 rich/legacy context、既有 note validator 与 claim transfer 边界。接收方接受建议不同于 `handoff adopt` 修改所有权。Stage A note 是可覆盖的当前 Todo 状态,不是私有不可变历史。后继方案将有权限的上下文映射为可恢复 brief,引用当前工作,补齐通用 assessment/result/自动回传关系,不新增 memory ledger 或复制 claim authority。迁移期间 CLI `prepare/inspect/adopt` 保持可用;前端/飞书及自动宿主续接单独验收。同机、注册 Agent、无 lease 限制继续适用于此 adapter,不约束所有通用请求。映射、迁移条件和保留负例见后继 §5.13。 +复用 rich/legacy context、既有 note validator 与 claim transfer 边界。接收方接受建议不同于 `handoff adopt` 修改所有权。Stage A note 是可覆盖的当前 Todo 状态,不是私有不可变历史。后继方案将有权限的上下文映射为可恢复 brief,引用当前工作,补齐通用 assessment/result/自动回传关系,不新增 memory ledger 或复制 claim authority。迁移期间 CLI `prepare/inspect/adopt` 保持可用;前端/飞书及自动宿主续接单独验收。同机、注册 Agent 限制继续适用于此 adapter,不约束所有通用请求;带租约工作使用下述显式转移路径。映射、迁移条件和保留负例见后继 §5.13。 后继 [§5.7](capable-manager-semantic-handoff-v0.zh-CN.md#57-会话与产品连续性) 明确区分原会话恢复、同 Agent 换 session、跨 Agent 接管,只有后者可能需要本 adapter 的所有权变更。自动 brief 捕获、来源消失恢复、执行 session fencing、回原入口报结论是明确的后续集成工作;可选 Obelisk recall 只补缺失历史证据,不替代 transfer grant。 @@ -27,17 +27,17 @@ inspect、验证并 adopt Todo。不实现自动上下文捕获、agent 无关 ## 实现分工 当前状态、稳定 Todo ID、revision 和 claim/lease 仍归现有 Todo coordination -边界所有;持久化复用内置 `file_v0` authority。CLI 只做宿主适配,状态规则由 +边界所有;持久化复用选定的 canonical File/SQLite authority。CLI 只做宿主适配,状态规则由 TypeScript 执行。不新增 capability、数据库、memory store、索引、发现、恢复 服务或所有权协议。历史检索与长期记忆复用可选 `decision_context` / `agent_turn_recall` provider;本流程不调用它们,也不依赖它们可用。 ## 可运行入口和范围 -需要已显式提升为本地 file authority 的 goal,以及由注册 agent 持有的 -open、active、无 lease Todo。现有说明写入不能证明 lease 执行实例权限, -所以阶段 A 明确拒绝 hard-lease goal 和带 lease 的 Todo;不会隐式提升、 -切换模式、释放别人的任务或回退读取 Markdown。 +需要已显式晋升的 canonical authority,以及注册 Agent 持有的 open、active Todo。 +无租约接力继续复用原 claim 事务。带租约工作必须提供当前执行 key/version, +所有权通过既有 `task-lease transfer --transfer-claim` 原子转移;本入口不晋升 +Goal、不改 handoff mode、不回退到 Markdown。 用户在同一宿主上显式 handoff 给另一个注册 agent。源会话将 revision 保护的 接续说明(传统 rationale 或 rich context)写入现有 Todo note;目标会话读取 @@ -144,7 +144,7 @@ Todo 投影的可见边界,没有独立 memory ACL;不得写入凭据或原 薄测试使用隔离真实 file authority,分别运行源、目标 Python CLI 进程,覆盖 正常重启、丢失确认、写入失败、artifact 缺失、revision 改变、他人接管、已完成、 -rich context 及传统向后兼容等场景。既有 claim/update 回归覆盖默认行为与 lease 拒绝。 +rich context 及传统向后兼容等场景。既有 claim/update 回归覆盖默认行为与缺失、过期、错误执行证明的拒绝。 ```sh node --experimental-strip-types --test tests/control_plane_ts/todo_continuation.test.ts @@ -157,5 +157,25 @@ node --experimental-strip-types --test tests/control_plane_ts/todo_continuation. 缺少 typed invariant 而被拒绝。 跨 agent transfer 已通过 typed transfer grant 在本交付中实现,由 handoff 流程 -(prepare/inspect/adopt)独占发起,普通 claim 无法构造该 grant。lease-bearing -Todo 的 transfer 留待现有所有权边界支持后再扩展,不在本次交付中另造协议。 +(prepare/inspect/adopt)独占发起,普通 claim 无法构造该 grant。带租约 Todo 的转移由既有 task-lease lifecycle 所有,adopt 不复制这份权限。 + + +## 带租约的接力与显示恢复 + +完整可运行命令见[英文镜像的租约流程](cross-session-memory-substrate-v0.md#leased-execution-continuation)。 +源 Agent 先凭当前 `--task-lease-idempotency-key` / `--task-lease-expected-version` +和 provider revision 准备上下文,再执行 `task-lease transfer --transfer-claim`。 +接收 Agent 用新 key/version inspect,取回当前 revision 后 adopt。版本号必须来自实际读回。 + +- inspect 无执行证明也能读取上下文,但带租约工作不会得到 `can_adopt=true`。 +- 原子 transfer 只延续转移前仍有效的 note,并在同一 CAS 内重绑定 owner 摘要。 + 已过时的 note、变化的任务要求不会被重新认证;普通 metadata 写入不能重绑定。 +- 带租约 adopt 记录绑定 Agent、session、note、revision 和执行证明的 receipt, + 不申请、续期或转移租约。返回 `adoption.changed=false`;无租约路径保留 `claim`。 + 当前权限必须重新读回,历史回执不能越过过期/释放的租约、换主、任务变化或验收 hold。 +- prepare/adopt 将当前 canonical Todo 投递至既有 Markdown 投影。显示失败保留 + 已提交结果,返回 `projection_delivery=pending`、`retry_business_mutation=false`。 + 修复本地显示输入后可重试同一操作,或运行 `todo project-markdown`;inspect 不写投影。 + +session ID 仍是来源说明,不是宿主认证或外部工具授权。这是显式 CLI 接力, +没有新增前端/飞书入口、自动启动目标会话或自动 manager/worker 派工能力。 diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.md new file mode 100644 index 0000000000..82886489c7 --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.md @@ -0,0 +1,45 @@ +# Explicit leased continuation closes a canonical CLI gap + +For #4574 G1/G2 and the shared-authority L2/L3 program, the explicit handoff +caller still rejected every hard-lease Todo even though metadata updates and +atomic claim/lease transfer already owned the necessary execution proof. It +also left committed prepare/adopt state waiting for Markdown delivery. + +The same-host CLI now composes those existing owners: prepare with current +proof, transfer claim and lease atomically, then receive context under the new +execution proof. Transfer rebinds only a note valid against its original work +facts. Leased adoption writes a context receipt without changing claim/lease; +receipt replay is separately checked against current execution and acceptance. +Python retains host file IO and projection delivery; the closed context schema +and lease/claim decisions stay in TS. The lease-free path remains compatible. + +The [operating contract](../../cross-session-memory-substrate-v0.md#leased-execution-continuation) +describes proof flags, retry/readback and the explicit host/session boundary. +This is not automatic delegation, host launch, independent result acceptance, +external-effect fencing or completion of the manager handoff RFC. No new UI +control is required for this existing CLI-only workflow. Existing Todo display +consumers receive the same record schema via permanent projection. + +## Remaining local-default program + +Retain the conditional **5–8 cohesive packages**, including integration of +already-open prerequisites. This completes one real caller path within L2/L3 +and fixes its display delivery; it does not retire either entire package. +SQLite remains the long-lived default candidate, File the reference/explicit +profile. “New Goal default”, “migrate existing Goals” and “delete all Python” +are separate outcomes. + +| Package | Estimate | Observable completion | +| --- | --- | --- | +| Remaining CLI/Turn/Chat callers and actual effects | 1–2 | Close the command matrix, exact execution proof and external-effect boundary; remove each replaced Python business rule with its last caller. | +| Consumer and permanent projection integration | 1 | Full readback/pagination and display recovery through affected packaged entry points; stale or missing Markdown cannot become authority. | +| SQLite D2 qualification, contributor-owned #4224/#4931 | 1–2 | Capacity/receipt/scan budgets, crash/restore/upgrade, platform coverage, consumer lag and at least ten genuinely elapsed days of soak. | +| Source capture plus whole-Goal migration | 1–2 | Sustained mixed writers and event-only coverage, drain/fence/readback, cohort rehearsal and recoverable export/rollback. | +| Default selection and legacy writer retirement | 1 | New-Goal creation/settings/install choose the qualified profile; explicit choices survive; obsolete business writers retire after migration windows close. | + +These are delivery packages, not a prediction that five more arbitrary small +PRs finish migration. The elapsed soak cannot be replaced by accelerated tests. +PostgreSQL shares typed command semantics and real backend conformance, but +service authentication, tenant isolation, operations, restore/failover and +capacity remain an independent medium-term qualification. Permanent Markdown +rendering, import/export and host adapters are not duplicate business owners. diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.zh-CN.md new file mode 100644 index 0000000000..151d9c8c61 --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.zh-CN.md @@ -0,0 +1,36 @@ +# 显式带租约接力:补齐 canonical CLI 的实际调用路径 + +对应 #4574 G1/G2 和 shared authority L2/L3。原 handoff caller 拒绝所有 +hard-lease Todo,虽然 metadata update 和原子 claim/lease transfer 已具备执行 +证明;prepare/adopt 已提交的结果也没有主动投递 Markdown。 + +本次复用已有所有者:源 Agent 凭当前证明准备上下文,原子转移 claim 与 lease, +接收 Agent 凭新证明接收上下文。transfer 只重绑定转移前仍有效的 note;adopt +记录上下文接收回执,不改变 claim/lease。回执重试仍需验证当前执行权和验收条件。 +Python 保留宿主 IO、投影投递;上下文闭合 schema、claim/lease 规则由 TS 所有。 +无租约路径保持兼容,不新增 capability 或第二套所有权协议。 + +[操作合同](../../cross-session-memory-substrate-v0.zh-CN.md#带租约的接力与显示恢复) +覆盖参数、重试和读回。这是已有 CLI 工作流,不涉及新 UI 控件;原 Todo 显示 +消费者继续读取同一记录 schema。自动派工、启动宿主、独立结果验收和外部 effect +fencing 仍由相应边界负责,不据此宣布 manager handoff RFC 完成。 + +## 距离本地默认的交付节奏 + +仍按条件化的 **5–8 个完整交付包** 规划,并集成已有在审前置 PR。本次关闭 +L2/L3 的一个实际调用路径及其显示缺口,不能扣掉整个 L2/L3 或 L5 包。 +SQLite 是长期本地默认候选,File 是参考/显式选择;新 Goal 默认、已有 Goal +迁移与删除全部 Python 是三个不同结果。 + +| 交付包 | 估计 | 完成标准 | +| --- | --- | --- | +| 剩余 CLI/Turn/Chat 调用方及真实 effect | 1–2 | 闭合命令矩阵、当前执行证明和外部 effect 边界;最后调用方迁移后删除对应 Python 业务规则。 | +| 消费者与永久投影集成 | 1 | 全量读回、分页、显示恢复与受影响的打包入口通过;旧/缺失 Markdown 不成为权威。 | +| SQLite D2,沿用贡献者 #4224/#4931 | 1–2 | 容量/receipt/scan 预算、崩溃/恢复/升级、平台覆盖、消费者延迟和至少十天真实经过的 soak。 | +| 捕获与整 Goal 迁移 | 1–2 | 持续混合 writer、event-only 覆盖、drain/fence/readback、cohort 演练与可恢复 export/rollback。 | +| 默认选择与旧 writer 退场 | 1 | 新 Goal 创建/设置/安装选择已资格化 profile;保留显式选择;迁移窗口结束后删除旧业务 writer。 | + +这些是完整交付包,不是再合任意五个小 PR 就切换。加速测试不能替代实际经过 +时间。PostgreSQL 复用 typed command 语义和真实后端 conformance,但服务认证、 +tenant 隔离、部署、恢复/故障切换与容量仍需中期资格化。永久 Markdown renderer、 +导入导出、宿主适配器不属于应删除的重复业务所有者。 diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 7312f31051..da9e4af471 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -3414,3 +3414,5 @@ not assumed — see beside each entry, and that this appendix exists for the directory it names. 2026-09-24: [Typed complete-source assembly and remaining delivery packages](ledger/shared-goal-authority-state-provider-v0/2026-09-24-source-capture.md) unify source construction, identity rejection and current-graph membership; L7/D2/D3 and provider defaults remain open. + +2026-09-24: [Leased continuation and remaining local-default packages](ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.md). diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index d51c41f9ee..0a54dfd69d 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -2618,3 +2618,5 @@ onboarding 仍按 **7–9 个完整 PR** 条件估计,不能按本次修复机 `examples/docs-governance-smoke.py` 校验条目的命名、每条旁边的中文镜像,以及账本目录所指向的这份附录确实存在。 2026-09-24:[完整源捕获的 TS 组装与剩余交付包](ledger/shared-goal-authority-state-provider-v0/2026-09-24-source-capture.zh-CN.md)统一源构造、身份拒绝和当前图成员规则;不关闭 L7/D2/D3 或启用默认 provider。 + +2026-09-24: [带租约接力与剩余本地默认交付包](ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.zh-CN.md). diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index c9652402da..492da03cb8 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1921,3 +1921,5 @@ preserved independently of query matching. See [semantics and rollback](../../re This advances T3/L5; it does not replace D2/D3 or flip a provider default. 2026-09-24: [Typed complete-source assembly and remaining delivery packages](ledger/shared-goal-authority-state-provider-v0/2026-09-24-source-capture.md) unify source construction, identity rejection and current-graph membership; L7/D2/D3 and provider defaults remain open. + +2026-09-24: [Leased continuation and remaining local-default packages](ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.md). 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 f280565d55..1cf81062b9 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 @@ -1480,3 +1480,5 @@ TS 摘要批次;Python 保留旧格式解码、公开字段筛选及渲染。 共享读取边界推进,不替代 D2/D3 或 provider 默认切换。 2026-09-24:[完整源捕获的 TS 组装与剩余交付包](ledger/shared-goal-authority-state-provider-v0/2026-09-24-source-capture.zh-CN.md)统一源构造、身份拒绝和当前图成员规则;不关闭 L7/D2/D3 或启用默认 provider。 + +2026-09-24: [带租约接力与剩余本地默认交付包](ledger/shared-goal-authority-state-provider-v0/2026-09-24-leased-continuation.zh-CN.md). diff --git a/docs/reference/canonical-lease-renew.md b/docs/reference/canonical-lease-renew.md index 2204822136..bdad520203 100644 --- a/docs/reference/canonical-lease-renew.md +++ b/docs/reference/canonical-lease-renew.md @@ -115,8 +115,10 @@ writers reject it instead of attempting two separate writes. One provider CAS commits `claimed_by`, source actor attribution, lease owner/key, version +1, epoch +1, events and the original receipt. Todo ID, dependencies, -requirements, evidence and lease scopes remain intact. Existing continuation -notes retain their bytes but become stale when their bound Todo facts change. +requirements, evidence and lease scopes remain intact. A currently valid explicit continuation note is rebound to the new claim in +that same CAS; previously stale notes and ordinary notes remain untouched. +Other work-fact changes still invalidate context. See the +[leased continuation workflow](../architecture/rfcs/cross-session-memory-substrate-v0.md#leased-execution-continuation). A same-agent new execution advances the lease generation without fabricating a claim edit. The old execution cannot update/complete the handed-over Todo; the recipient still uses the ordinary proof-bearing update and completion commands. diff --git a/loopx/cli_commands/todo_continuation.py b/loopx/cli_commands/todo_continuation.py index 3cdbbc8c57..9064c03a89 100644 --- a/loopx/cli_commands/todo_continuation.py +++ b/loopx/cli_commands/todo_continuation.py @@ -7,35 +7,6 @@ from ..paths import resolve_runtime_root from ..control_plane.effect_runtime import effect_runtime_result -# Context-only keys: the --from-context JSON payload may ONLY contain -# these fields. Any operational key (action, agent_id, goal_id, etc.) is -# rejected before the payload reaches the TypeScript layer. This prevents -# a context file from overriding CLI-derived command/identity/authority -# metadata. -_CONTEXT_KEYS = { - "work_summary", "rationale", "source_refs", - "approaches_tried", "next_steps", "files_touched", - "key_decisions", "open_questions", -} - - -def _validate_context_payload(context: object) -> dict: - """Validate that a --from-context JSON value is an object with only - context-only keys. Returns the validated dict. Raises ValueError with - an actionable message on any violation.""" - if not isinstance(context, dict): - raise ValueError( - "--from-context must be a JSON object (work_summary, rationale, etc.); " - f"got {type(context).__name__}" - ) - unknown = sorted(set(context.keys()) - _CONTEXT_KEYS) - if unknown: - raise ValueError( - f"unknown context field: {', '.join(unknown)}. " - f"Allowed: {', '.join(sorted(_CONTEXT_KEYS))}" - ) - return context - def _render_digest(payload: dict) -> str: """Render an inspect payload as a readable handoff digest for the target agent.""" @@ -46,6 +17,10 @@ def _render_digest(payload: dict) -> str: source = payload.get("claimed_by", "unknown") note_state = payload.get("note_state", "missing") lines.append(f"Source: {source} | Status: {note_state}") + lines.append(f"Can adopt: {'yes' if payload.get('can_adopt') else 'no'}") + execution = payload.get("execution_authority") + if isinstance(execution, dict) and execution.get("allowed") is False: + lines.append(f"Execution authority: {execution.get('reason_code', 'unavailable')}") lines.append("") digest = payload.get("digest") if digest: @@ -113,7 +88,7 @@ def _render_digest(payload: dict) -> str: def register_todo_continuation(subparsers, add_format): parser = subparsers.add_parser( - "handoff", help="Explicit cross-agent Todo handoff: prepare, inspect, adopt (local lease-free authority)." + "handoff", help="Explicit cross-agent Todo handoff: prepare, inspect, adopt (selected canonical authority)." ) # Note: we don't use add_format here because we need a custom --format # with a 'digest' choice. We add it manually below. @@ -129,6 +104,8 @@ def register_todo_continuation(subparsers, add_format): parser.add_argument("--workspace", default=".", help="Target workspace to check locally; not persisted.") parser.add_argument("--artifact", action="append", default=[], help="Required workspace-relative artifact to check locally.") parser.add_argument("--target-agent-id", help="For adopt: the registered agent to hand off to (default: current agent).") + parser.add_argument("--task-lease-idempotency-key", help="Current execution key; never a transfer grant.") + parser.add_argument("--task-lease-expected-version", type=int, help="Current lease version paired with the execution key.") parser.add_argument("--from-context", help="Path to a JSON file containing rich handoff context (work_summary, approaches_tried, next_steps, files_touched, key_decisions, open_questions).") parser.add_argument("--format", dest="handoff_format", choices=["markdown", "json", "digest"], help="Output format. 'digest' renders inspect as a readable handoff summary (inspect only).") @@ -148,8 +125,7 @@ def handle_todo_continuation(args, *, registry_path, runtime_root_arg, output_fo raise ValueError("--format digest is only valid for inspect action") registry = load_registry(registry_path) root = resolve_runtime_root(registry, runtime_root_arg) - # Build the payload. If --from-context is provided, read the JSON file - # and merge its fields into the request. + # Nest context separately; TypeScript owns its closed semantic schema. request = { "runtime_root": str(root), "goal_id": args.goal_id, @@ -169,21 +145,27 @@ def handle_todo_continuation(args, *, registry_path, runtime_root_arg, output_fo context_path = Path(args.from_context).expanduser().resolve() with open(context_path, "r", encoding="utf-8") as f: context = json.load(f) - # Validate context is an object with only context-only keys - # BEFORE placing it in the request. This prevents a context - # file from overriding CLI-derived command/identity/authority - # metadata (action, agent_id, goal_id, etc.). - request["context"] = _validate_context_payload(context) + request["context"] = context else: # Legacy: pass through rationale and source_refs. if args.rationale: request["rationale"] = args.rationale if args.source_ref: request["source_refs"] = args.source_ref + key = args.task_lease_idempotency_key + version = args.task_lease_expected_version + if key is not None or version is not None: + request["lease_proof"] = {"idempotency_key": key, "expected_version": version} payload = effect_runtime_result("coordination.local_authority.todo_continuation", request) except (ValueError, RuntimeError, OSError, json.JSONDecodeError) as exc: payload = {"ok": False, "status": "failed", "reason_code": "invalid_continuation_request", "reason": str(exc)} + # Delivery retries project the current provider head without replaying a write. + committed = payload.get("result") or payload.get("claim") or payload.get("adoption") or {} + if args.action != "inspect" and committed.get("status") in {"applied", "replayed", "recovered", "no_change"}: + from ..control_plane.todos.provider_projection import settle_canonical_todo_projection + payload = settle_canonical_todo_projection(payload, registry_path=registry_path, + runtime_root=root, goal_id=args.goal_id) # Determine output format. handoff_format = getattr(args, "handoff_format", None) if handoff_format: diff --git a/loopx/control_plane/coordination/continuation_adoption.ts b/loopx/control_plane/coordination/continuation_adoption.ts new file mode 100644 index 0000000000..618694dcd0 --- /dev/null +++ b/loopx/control_plane/coordination/continuation_adoption.ts @@ -0,0 +1,72 @@ +/** A receipt for receiving context under an existing lease. This does not + * acquire, renew or transfer either a claim or an execution lease. */ +import type {JsonObject} from "../effect_program.ts"; +import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts"; +import type {AuthorityStore} from "./authority_store.ts"; +import {AuthorityStoreProtocolError, canonicalAuthoritySha256} from "./authority_store_codec.ts"; +import {CoordinationCommandReceipt, commandReceiptResult} from "./command_receipt.ts"; +import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "./coordination_projection.ts"; +import {computeContinuationTodoFacts, validateContinuationNote} from "./continuation_note.ts"; +import {evaluateCanonicalTaskLeaseProof, type TaskLeaseProof} from "./task_lease_proof.ts"; + +interface ExecutionInput { + goal_id: string; todo_id: string; agent_id: string; + registered_agents: readonly string[]; proof: TaskLeaseProof | null; +} + +export function continuationExecutionAuthority(head: JsonObject, input: ExecutionInput) { + validateCoordinationTodoReadModel(head, input.goal_id); + const index = indexCoordinationProjection(head, input.goal_id); + const todo = index.todos.get(input.todo_id); + if (!todo) return {allowed: false, reason_code: "todo_not_found"}; + const guard = acceptanceWorkGuard(head, input.goal_id, input.todo_id); + if (guard !== null && !guard.allowed) return {allowed: false, reason_code: String(guard.reason_code)}; + const decision = evaluateCanonicalTaskLeaseProof({todo, lease: index.leases.get(input.todo_id), + handoff_mode: String(head.handoff_mode ?? "legacy"), actor_agent_id: input.agent_id, + registered_agents: input.registered_agents, lease_idempotency_key: input.proof?.idempotency_key ?? null, + lease_expected_version: input.proof?.expected_version ?? null, now: new Date()}); + return {allowed: decision.outcome === "apply", reason_code: String(decision.code)}; +} + +export async function sealLeasedContinuationAdoption(store: AuthorityStore, input: ExecutionInput & { + operation_id: string; session_id: string; expected_provider_revision: string; note_facts: string; +}): Promise { + const resultSchema = "loopx_continuation_adoption_result_v0"; + const failure = (reason_code: string, reason: string) => + ({schema_version: resultSchema, status: "rejected", changed: false, reason_code, reason}); + const {registered_agents: _registered, ...intent} = input; + const identity = {schema_version: "loopx_continuation_adoption_receipt_v0", + operation_id: input.operation_id, goal_id: input.goal_id, request_sha256: canonicalAuthoritySha256(intent)}; + const receipt = new CoordinationCommandReceipt({result_schema: resultSchema, identity, failure, + decode(original) { + const payload = commandReceiptResult(original); + if (payload.changed || payload.fields.adopted !== true || payload.fields.todo_id !== input.todo_id || + payload.fields.agent_id !== input.agent_id || payload.fields.note_facts !== input.note_facts) { + throw new AuthorityStoreProtocolError("adoption receipt does not match the accepted context"); + } + return payload; + }}); + const replay = await receipt.read(store); + if (replay !== null) return replay; + const observation = await receipt.observe(store); + if (observation.kind === "receipt") return observation.result; + const head = observation.authority; + if (head.status !== "loaded") return {schema_version: resultSchema, ...head, changed: false}; + if (head.provider_revision !== input.expected_provider_revision) { + return failure("provider_revision_mismatch", "Inspect the current context and lease before adopting"); + } + const execution = continuationExecutionAuthority(head.head, input); + if (!execution.allowed) return failure(execution.reason_code, "Current execution authority does not admit adoption"); + const todo = indexCoordinationProjection(head.head, input.goal_id).todos.get(input.todo_id)!; + const note = validateContinuationNote(todo.note, computeContinuationTodoFacts(todo)); + if (todo.claimed_by !== input.agent_id || !note.valid || note.noteFacts !== input.note_facts || + note.note?.source_session === input.session_id) { + return failure("continuation_not_ready", "Current owner, context or source session changed"); + } + // Receipt-only CAS: seal the context decision without inventing a Todo edit. + // The caller separately rereads current authority after both commit and replay. + return receipt.commit(store, {operation_id: input.operation_id, + expected_provider_revision: head.provider_revision, next_projection: head.head, events: [], + receipts: [{...identity, result: {changed: false, adopted: true, todo_id: input.todo_id, + agent_id: input.agent_id, note_facts: input.note_facts}}]}); +} diff --git a/loopx/control_plane/coordination/continuation_note.ts b/loopx/control_plane/coordination/continuation_note.ts index 2e985e4438..8a80bd9d3b 100644 --- a/loopx/control_plane/coordination/continuation_note.ts +++ b/loopx/control_plane/coordination/continuation_note.ts @@ -58,17 +58,9 @@ export interface ContinuationNoteValidation { readonly noteFacts: string; } -// Exact closed bounded schema for continuation notes. This is the single -// source of truth shared by both the producer (buildContextFromInput) and the -// final claim authority (validateContinuationNote). Every constraint here -// MUST match what the producer can generate — otherwise the final authority -// accepts notes that no producer can create. -// -// Root-level allowed keys: -const NOTE_ROOT_KEYS = new Set([ - "kind", - "source_session", - "todo_facts", +// Allowed keys for the raw --from-context input (before producer adds +// kind/source_session/todo_facts). This is the public input contract. +export const CONTEXT_ROOT_KEYS: ReadonlySet = new Set([ "work_summary", "rationale", "source_refs", @@ -79,6 +71,17 @@ const NOTE_ROOT_KEYS = new Set([ "open_questions", ]); +// Exact closed bounded schema for continuation notes. This is the single +// source of truth shared by both the producer (buildContextFromInput) and the +// final claim authority (validateContinuationNote). Every constraint here +// MUST match what the producer can generate — otherwise the final authority +// accepts notes that no producer can create. +// +// Root-level allowed keys: +const NOTE_ROOT_KEYS = new Set([ + "kind", "source_session", "todo_facts", ...CONTEXT_ROOT_KEYS, +]); + // Per-field max lengths. Must match buildContextFromInput bounds exactly. const FIELD_MAX = { source_session: 160, @@ -104,7 +107,7 @@ function boundedString(value: unknown, name: string, max: number): value is stri return typeof value === "string" && value.trim().length > 0 && value.length <= max; } -function hasOnlyKeys(obj: Record, allowed: Set): boolean { +function hasOnlyKeys(obj: Record, allowed: ReadonlySet): boolean { return Object.keys(obj).every(k => allowed.has(k)); } @@ -134,25 +137,12 @@ function isDecision(value: unknown): value is ContinuationNoteDecision { boundedString(v.rationale, "decision_rationale", FIELD_MAX.decision_rationale); } -// Allowed keys for the raw --from-context input (before producer adds -// kind/source_session/todo_facts). This is the public input contract. -const CONTEXT_ROOT_KEYS = new Set([ - "work_summary", - "rationale", - "source_refs", - "approaches_tried", - "next_steps", - "files_touched", - "key_decisions", - "open_questions", -]); - // Validate the raw --from-context input against the closed bounded schema. // Throws on the first violation: unknown root key, wrong type, or bounds // overflow. This runs at the earliest boundary — before any field reaches // the persisted note — so the producer can never silently sanitize bad // input into a smaller but legal note. -export function validateRawContext(input: JsonObject): void { +export function validateRawContext(input: JsonObject): asserts input is JsonObject & ContinuationNoteContext { if (!hasOnlyKeys(input, CONTEXT_ROOT_KEYS)) { const extra = Object.keys(input).filter(k => !CONTEXT_ROOT_KEYS.has(k)); throw new Error(`unknown context field: ${extra.join(", ")}`); @@ -306,3 +296,15 @@ export function buildContinuationNote( if (context.open_questions) note.open_questions = context.open_questions; return note; } + +/** Only an authorized claim-transfer planner may carry a current note forward. + * Keep owner binding in the fingerprint: arbitrary claim edits still stale it. + * Never refresh a note whose work facts had already changed before transfer. */ +export function carryContinuationAcrossClaimTransfer(before: JsonObject, after: JsonObject): JsonObject { + const validation = validateContinuationNote(before.note, computeContinuationTodoFacts(before)); + if (!validation.valid) return after; + const expected = {...before, claimed_by: after.claimed_by}; + if (computeContinuationTodoFacts(expected) !== computeContinuationTodoFacts(after)) return after; + return {...after, note: JSON.stringify({...validation.note, + todo_facts: computeContinuationTodoFacts(after)})}; +} diff --git a/loopx/control_plane/coordination/lease_claim_transfer.ts b/loopx/control_plane/coordination/lease_claim_transfer.ts index f0a967caa7..424a65326d 100644 --- a/loopx/control_plane/coordination/lease_claim_transfer.ts +++ b/loopx/control_plane/coordination/lease_claim_transfer.ts @@ -1,6 +1,7 @@ /** Plan the Todo half of an explicit lease/claim handover on the same head. * The lifecycle owner must still verify the lease tuple and commit both halves. * This grants neither delegated Todo administration nor external-effect fencing. */ +import {carryContinuationAcrossClaimTransfer} from "./continuation_note.ts"; import type {JsonObject} from "../effect_program.ts"; import {registeredTodoMutationRejection} from "./todo_lifecycle_decision.ts"; @@ -32,5 +33,6 @@ export function planLeaseClaimTransfer(input: { const targetRejection = registeredTodoMutationRejection(next, target, registered); if (targetRejection) return {status: "rejected", code: targetRejection}; if (target === owner) return {status: "unchanged", todo}; - return {status: "transfer", todo: {...next, updated_at: input.updated_at, last_actor_agent_id: owner}}; + return {status: "transfer", todo: carryContinuationAcrossClaimTransfer(todo, + {...next, updated_at: input.updated_at, last_actor_agent_id: owner})}; } diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index c51977ea3f..d233c4d2bb 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1469,7 +1469,7 @@ export async function continueLocalTodo( const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status !== "loaded") return {ok: false, status: "rejected", reason_code: "continuation_requires_canonical_authority", - reason: "Use an explicitly promoted local file authority; this command never promotes or falls back to Markdown"}; + reason: "Use an explicitly promoted canonical authority; this command never promotes or falls back to Markdown"}; return {...await executeTodoContinuation(store, input), ...evidence}; }); } catch (error) { diff --git a/loopx/control_plane/coordination/todo_continuation.ts b/loopx/control_plane/coordination/todo_continuation.ts index b7c6f625c9..b321a5467f 100644 --- a/loopx/control_plane/coordination/todo_continuation.ts +++ b/loopx/control_plane/coordination/todo_continuation.ts @@ -18,6 +18,8 @@ import type {AuthorityStore} from "./authority_store.ts"; import {canonicalAuthorityObject, canonicalAuthoritySha256, requireAuthorityStoreId} from "./authority_store_codec.ts"; import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "./coordination_projection.ts"; import {executeCoordinationTodoUpdate} from "./todo_update.ts"; +import {decodeTaskLeaseProof} from "./task_lease_proof.ts"; +import {continuationExecutionAuthority, sealLeasedContinuationAdoption} from "./continuation_adoption.ts"; import {executeCoordinationTodoClaim} from "./todo_claim.ts"; import { CONTINUATION_NOTE_MARKER, @@ -27,9 +29,7 @@ import { validateRawContext, type ContinuationNoteContext, type ContinuationNoteValidation, - type ContinuationNoteApproachTried, - type ContinuationNoteFileTouched, - type ContinuationNoteDecision, + CONTEXT_ROOT_KEYS, } from "./continuation_note.ts"; const accepted = new Set(["applied", "replayed", "recovered", "no_change"]); @@ -80,25 +80,20 @@ function buildContextFromInput(input: JsonObject): ContinuationNoteContext { // 2. Direct TypeScript callers: context fields passed flat (backward // compat with unit tests). Request keys are allowed but only // context keys are extracted. - const contextKeys = new Set(["work_summary", "rationale", "source_refs", "approaches_tried", - "next_steps", "files_touched", "key_decisions", "open_questions"]); + const contextKeys = CONTEXT_ROOT_KEYS; const requestKeys = new Set(["goal_id", "todo_id", "agent_id", "registered_agents", "action", "session_id", "operation_id", "expected_provider_revision", "workspace", "artifacts", - "target_agent_id", "handoff_format", "runtime_root"]); + "target_agent_id", "handoff_format", "runtime_root", "lease_proof"]); // Resolve the raw context source. let rawContext: JsonObject; if (input.context !== undefined) { // CLI path: context is in the dedicated field. Validate it is an object. if (typeof input.context !== "object" || input.context === null || Array.isArray(input.context)) { - throw new Error("context must be an object with context-only fields (work_summary, rationale, etc.)"); + throw new Error("context must be a JSON object with context-only fields (work_summary, rationale, etc.)"); } rawContext = input.context as JsonObject; - // Only context keys are allowed. Reject ALL operational keys. - for (const key of Object.keys(rawContext)) { - if (!contextKeys.has(key)) { - throw new Error(`unknown context field: ${key}. Context may only contain: ${[...contextKeys].join(", ")}`); - } - } + validateRawContext(rawContext); + return rawContext; } else { // Backward compat: flat fields. Allow both context and request keys. rawContext = input; @@ -121,16 +116,7 @@ function buildContextFromInput(input: JsonObject): ContinuationNoteContext { // All fields are already validated — pass them through directly without // any second sanitization. The producer's output shape is guaranteed to // match what validateContinuationNote() will see later. - return { - work_summary: context.work_summary as string | undefined, - rationale: context.rationale as string | undefined, - source_refs: context.source_refs as readonly string[] | undefined, - approaches_tried: context.approaches_tried as readonly ContinuationNoteApproachTried[] | undefined, - next_steps: context.next_steps as readonly string[] | undefined, - files_touched: context.files_touched as readonly ContinuationNoteFileTouched[] | undefined, - key_decisions: context.key_decisions as readonly ContinuationNoteDecision[] | undefined, - open_questions: context.open_questions as readonly string[] | undefined, - }; + return context; } export async function executeTodoContinuation(store: AuthorityStore, value: unknown): Promise { @@ -149,6 +135,7 @@ export async function executeTodoContinuation(store: AuthorityStore, value: unkn if (targetAgentId != null && !registered.includes(targetAgentId)) { return reject("target_agent_not_registered", "Target agent must be registered for this goal"); } + const proof = decodeTaskLeaseProof(input.lease_proof); const head = await store.loadAuthority(); if (head.status !== "loaded") return {ok: false, ...head}; validateCoordinationTodoReadModel(head.head, goalId); @@ -156,11 +143,12 @@ export async function executeTodoContinuation(store: AuthorityStore, value: unkn const todo = projection.todos.get(todoId); if (!todo) return reject("todo_not_found", "The stable Todo ID no longer exists; do not recreate it from text"); if (todo.status !== "open" || todo.archive_state !== "active") return reject("todo_not_open", "Todo is no longer open and active"); - // The existing metadata writer cannot yet prove a lease-bearing note update. - // Preserve its boundary instead of inventing a second lease/transfer protocol. - if (![undefined, "legacy", "soft_claim"].includes(head.head.handoff_mode as string | undefined) || projection.leases.has(todoId)) { - return reject("continuation_lease_unsupported", "Stage A supports lease-free local Todos only; use existing lease/handoff commands for leased work"); - } + const leased = ![undefined, "legacy", "soft_claim"].includes(head.head.handoff_mode as string | undefined) + || projection.leases.has(todoId); + const authorityInput = {goal_id: goalId, todo_id: todoId, agent_id: agentId, + registered_agents: registered, proof}; + const execution = leased ? continuationExecutionAuthority(head.head, authorityInput) + : {allowed: true, reason_code: "lease_not_required"}; const common = {goal_id: goalId, todo_id: todoId, expected_role: "agent", actor_agent_id: agentId, registered_agents: registered, dry_run: false, now: new Date()}; if (input.action === "prepare") { @@ -172,7 +160,8 @@ export async function executeTodoContinuation(store: AuthorityStore, value: unkn const result = await executeCoordinationTodoUpdate(store, {...common, operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), expected_provider_revision: requireAuthorityStoreId(input.expected_provider_revision, "expected revision"), - patch: {note}, clear_fields: []}); + patch: {note}, clear_fields: [], + lease_idempotency_key: proof?.idempotency_key, lease_expected_version: proof?.expected_version}); const current = await store.loadAuthority(); const verified = current.status === "loaded" && indexCoordinationProjection(current.head, goalId).todos.get(todoId)?.note === note; @@ -201,10 +190,20 @@ export async function executeTodoContinuation(store: AuthorityStore, value: unkn note_state: validNote ? "current" : note?.kind === CONTINUATION_NOTE_MARKER ? "stale" : "missing", availability: environment, decision_rationale: validNote ? note!.rationale : null, evidence_refs: [`todo:${todoId}`, `revision:${head.provider_revision}`, ...(validNote && Array.isArray(note!.source_refs) ? note!.source_refs as string[] : [])], - next_step: validNote && environment.ready === true ? todo.text : "Restore workspace/artifacts and ask the source session to prepare a current decision note", - can_adopt: validNote && environment.ready === true && note!.source_session !== sessionId, + next_step: leased && !execution.allowed + ? "Resolve the execution_authority gap; use task-lease transfer --transfer-claim when changing owner" + : validNote && environment.ready === true ? todo.text : "Restore workspace/artifacts and ask the source session to prepare a current decision note", + ...(leased ? {execution_authority: execution} : {}), + can_adopt: validNote && environment.ready === true && note!.source_session !== sessionId && + (!leased || (execution.allowed && (targetAgentId === null || targetAgentId === agentId))), digest}; if (input.action === "inspect") return packet; + if (leased && (todo.claimed_by !== agentId || (targetAgentId !== null && targetAgentId !== agentId))) { + return {...packet, ...reject("continuation_transfer_required", + "The source must use task-lease transfer --transfer-claim; the receiver then adopts with its own current lease proof")}; + } + if (leased && !execution.allowed) return {...packet, ...reject(execution.reason_code, + "Adoption requires the current execution lease and admission; inspect the reported authority gap")}; if (!packet.can_adopt) return {...packet, ...reject("continuation_not_ready", "Inspect and repair the reported note/session/availability gap before adopting")}; const adoptOwnerId = targetAgentId ?? agentId; const preClaimFacts = stableFacts(todo); @@ -221,7 +220,12 @@ export async function executeTodoContinuation(store: AuthorityStore, value: unkn expected_revision: requireAuthorityStoreId(input.expected_provider_revision, "expected revision"), continuation_note_facts: noteValidation.noteFacts, } : undefined; - const result = await executeCoordinationTodoClaim(store, {...common, claimed_by: adoptOwnerId, + const result = leased + ? await sealLeasedContinuationAdoption(store, {...authorityInput, session_id: sessionId, + operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + expected_provider_revision: requireAuthorityStoreId(input.expected_provider_revision, "expected revision"), + note_facts: noteValidation.noteFacts}) + : await executeCoordinationTodoClaim(store, {...common, claimed_by: adoptOwnerId, operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), expected_provider_revision: requireAuthorityStoreId(input.expected_provider_revision, "expected revision"), transfer_grant: transferGrant}); @@ -230,8 +234,9 @@ export async function executeTodoContinuation(store: AuthorityStore, value: unkn const currentTodo = current.status === "loaded" ? indexCoordinationProjection(current.head, goalId).todos.get(todoId) : undefined; const verified = currentTodo?.status === "open" && currentTodo.archive_state === "active" && currentTodo.claimed_by === adoptOwnerId && currentTodo.note === todo.note && stableFacts(currentTodo) === preClaimFacts && - current.status === "loaded" && current.provider_revision === result.provider_revision; + current.status === "loaded" && current.provider_revision === result.provider_revision && + (!leased || continuationExecutionAuthority(current.head, authorityInput).allowed); return {...packet, action: "adopt", ok: accepted.has(String(result.status)) && verified, - claim: result, current_authority_verified: verified, target_agent_id: adoptOwnerId, + ...(leased ? {adoption: result} : {claim: result}), current_authority_verified: verified, target_agent_id: adoptOwnerId, provider_revision: current.status === "loaded" ? current.provider_revision : null}; } diff --git a/tests/control_plane_ts/claim_transfer_conformance.ts b/tests/control_plane_ts/claim_transfer_conformance.ts index c9f8703c18..2ceef576bc 100644 --- a/tests/control_plane_ts/claim_transfer_conformance.ts +++ b/tests/control_plane_ts/claim_transfer_conformance.ts @@ -41,7 +41,7 @@ export function registerClaimTransferConformance(provider: string, factory: Auth } for (const schema of ["native", "legacy"] as const) { - test(`${provider} ${schema} atomic claimed lease transfer preserves the complete work graph`, async t => { + test(`${provider} ${schema} atomic claimed lease transfer preserves current context and the complete work graph`, async t => { const {store, contender, seed, request} = await setup(t, schema); const result = await execute(store, request); assert.equal(result.status, "applied", JSON.stringify(result)); @@ -50,8 +50,10 @@ export function registerClaimTransferConformance(provider: string, factory: Auth assert.equal(head.cursor, "2"); const current = (head.head.todos as JsonObject[]).find(row => row.todo_id === request.todo_id)!; const original = (seed.todos as JsonObject[]).find(row => row.todo_id === request.todo_id)!; - assert.equal(validateContinuationNote(current.note, computeContinuationTodoFacts(current)).valid, false); - assert.deepEqual(current, {...original, claimed_by: "agent-b", last_actor_agent_id: "agent-a", updated_at: "2026-09-13T10:05:00Z"}); + assert.equal(validateContinuationNote(current.note, computeContinuationTodoFacts(current)).valid, true); + assert.deepEqual(JSON.parse(String(current.note)), {...JSON.parse(String(original.note)), + todo_facts: computeContinuationTodoFacts({...original, claimed_by: "agent-b"})}); + assert.deepEqual(current, {...original, note: current.note, claimed_by: "agent-b", last_actor_agent_id: "agent-a", updated_at: "2026-09-13T10:05:00Z"}); const originalLease = (seed.leases as JsonObject[]).find(row => row.todo_id === request.todo_id)!; assert.deepEqual(result.lease, {...originalLease, owner: "agent-b", idempotency_key: "lifecycle-b", version: 4, lease_epoch: 8, updated_at: "2026-09-13T10:05:00Z", expires_at: "2026-09-13T10:15:00Z"}); diff --git a/tests/control_plane_ts/leased_continuation.test.ts b/tests/control_plane_ts/leased_continuation.test.ts new file mode 100644 index 0000000000..9e0b08c24e --- /dev/null +++ b/tests/control_plane_ts/leased_continuation.test.ts @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import {randomUUID} from "node:crypto"; +import {mkdtemp, rm, writeFile, readFile, mkdir} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; +import {execFile} from "node:child_process"; +import {promisify} from "node:util"; +import {openLocalAuthorityStore, selectLocalSqliteAuthority} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {engageLegacyCoordinationWriterFence} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {Pool} from "pg"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {PostgreSqlAuthorityStore, installPostgreSqlAuthorityStoreSchema} from "../../loopx/control_plane/coordination/postgresql_authority_store.ts"; +import {configureGoalAcceptance} from "../../loopx/control_plane/goals/acceptance_authority.ts"; +import {executeTodoContinuation} from "../../loopx/control_plane/coordination/todo_continuation.ts"; +import {executeCanonicalTaskLeaseLifecycle} from "../../loopx/control_plane/coordination/task_lease_lifecycle.ts"; +import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; +import {indexCoordinationProjection, prepareCoordinationProjectionCommit} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {productionScaleCoordinationFixture, PRODUCTION_SCALE_VALIDATION_DECLARATION} from "./production_scale_coordination_fixture.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; + +// The CLI under test is the source checkout's own interpreter, never a bare +// `python3` alias that may be an incompatible system interpreter. +const executeFile = promisify(execFile); +const PYTHON = resolveTestPython(); + +async function loaded(store: AuthorityStore) { + const head = await store.loadAuthority(); + assert.equal(head.status, "loaded"); + if (head.status !== "loaded") throw new Error("fixture must be loaded"); + return head; +} + +async function fixture(t: test.TestContext, provider: string) { + const root = await mkdtemp(join(tmpdir(), "leased-continuation-")); + t.after(() => rm(root, {recursive: true, force: true})); + let store: AuthorityStore; + if (provider === "postgresql") { + const pool = new Pool({connectionString: process.env.LOOPX_TEST_POSTGRES_URL, max: 2}); + t.after(() => pool.end()); + const database = {connect: async () => { + const client = await pool.connect(); + return {query: async (sql: string, values?: readonly unknown[]) => client.query(sql, values ? [...values] : undefined), + release: () => client.release()}; + }}; + await installPostgreSqlAuthorityStoreSchema(database, `postgresql:${"b".repeat(32)}`); + store = new PostgreSqlAuthorityStore(database, {tenant_id: `continuation-${randomUUID()}`, goal_id: "goal-a"}); + } else { + if (provider === "sqlite") await selectLocalSqliteAuthority(root, "goal-a", true); + store = await openLocalAuthorityStore(root, "goal-a"); + } + const scale = productionScaleCoordinationFixture("goal-a", "native"); + assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: scale.projection, events: [], receipts: []})).status, "applied"); + const base = {goal_id: "goal-a", todo_id: scale.completion_todo_id, agent_id: "agent-a", + registered_agents: scale.registered_agents, session_id: "source", workspace: root}; + const sourceProof = {idempotency_key: scale.completion_lease_idempotency_key, + expected_version: scale.completion_lease_expected_version}; + const receiverProof = {idempotency_key: "receiver-execution", expected_version: sourceProof.expected_version + 1}; + const prepare = {...base, action: "prepare", operation_id: "prepare", lease_proof: sourceProof, + expected_provider_revision: (await loaded(store)).provider_revision, + context: {work_summary: "The implementation is ready for independent validation", next_steps: ["Run the declared check"]}}; + const transfer = () => executeCanonicalTaskLeaseLifecycle(store, {operation: "transfer", goal_id: base.goal_id, + todo_id: base.todo_id, owner: base.agent_id, idempotency_key: sourceProof.idempotency_key, + expected_version: sourceProof.expected_version, new_owner: "agent-b", new_idempotency_key: receiverProof.idempotency_key, + ttl_seconds: 2700, transfer_claim: true, registered_agents: base.registered_agents, now: new Date()}); + const receiver = {...base, agent_id: "agent-b", session_id: "receiver", lease_proof: receiverProof}; + const inspect = () => executeTodoContinuation(store, {...receiver, action: "inspect"}); + const adopt = async () => ({...receiver, action: "adopt", operation_id: "adopt", + expected_provider_revision: (await loaded(store)).provider_revision}); + return {root, store, base, prepare, sourceProof, receiverProof, transfer, receiver, inspect, adopt, scale}; +} + +async function patch(store: AuthorityStore, todoId: string, extra: JsonObject) { + const head = await loaded(store); + const todo = indexCoordinationProjection(head.head, "goal-a").todos.get(todoId)!; + assert.equal((await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: "goal-a", + operation_id: `change-${randomUUID()}`, expected_provider_revision: head.provider_revision, + projection: head.head, mutations: [{kind: "todo_upsert", todo: {...todo, ...extra}}]}))).status, "applied"); +} + +for (const provider of ["file", "sqlite", "postgresql"]) { + test(`${provider}: leased context crosses claim transfer, durable adoption and current execution readback`, + {skip: provider === "postgresql" && !process.env.LOOPX_TEST_POSTGRES_URL ? "isolated PostgreSQL URL required" : false}, async t => { + const f = await fixture(t, provider); + const before = await loaded(f.store); + const prepared = await executeTodoContinuation(f.store, f.prepare); + assert.equal(prepared.ok, true, JSON.stringify(prepared)); + assert.equal((await executeTodoContinuation(f.store, f.prepare)).ok, true); + const sourceNote = indexCoordinationProjection((await loaded(f.store)).head, "goal-a").todos.get(f.base.todo_id)!.note; + assert.equal((await f.inspect()).can_adopt, false); + const stolen = await executeTodoContinuation(f.store, await f.adopt()); + assert.equal(stolen.reason_code, "continuation_transfer_required"); + const transferred = await f.transfer(); + assert.equal(transferred.status, "applied", JSON.stringify(transferred)); + const packet = await f.inspect(); + assert.equal(packet.note_state, "current"); + assert.equal(packet.can_adopt, true, JSON.stringify(packet)); + const head = await loaded(f.store); + const todo = indexCoordinationProjection(head.head, "goal-a").todos.get(f.base.todo_id)!; + assert.notEqual(todo.note, sourceNote); // Only the exact owner-bound fingerprint changes. + assert.equal(JSON.parse(String(todo.note)).work_summary, f.prepare.context.work_summary); + const request = await f.adopt(); + const adopted = await executeTodoContinuation(f.store, request); + assert.equal(adopted.ok, true, JSON.stringify(adopted)); + assert.equal(adopted.current_authority_verified, true); + assert.equal((adopted.adoption as JsonObject).changed, false); + assert.equal((await executeTodoContinuation(f.store, request)).ok, true); + const adoptedHead = await loaded(f.store); + assert.deepEqual(adoptedHead.head, head.head); // Receipt cannot mutate claim, lease or unrelated work. + assert.equal((adoptedHead.head.todos as unknown[]).length, (before.head.todos as unknown[]).length); + const update = {goal_id: "goal-a", todo_id: f.base.todo_id, expected_role: "agent", + registered_agents: f.base.registered_agents, dry_run: false, now: new Date(), clear_fields: [], patch: {text: "Receiver continues the validated implementation"}}; + const former = await executeCoordinationTodoUpdate(f.store, {...update, actor_agent_id: "agent-a", operation_id: "old-owner", + lease_idempotency_key: f.sourceProof.idempotency_key, lease_expected_version: f.sourceProof.expected_version}); + assert.equal(former.status, "failed"); + const continued = await executeCoordinationTodoUpdate(f.store, {...update, actor_agent_id: "agent-b", operation_id: "receiver-work", + lease_idempotency_key: f.receiverProof.idempotency_key, lease_expected_version: f.receiverProof.expected_version}); + assert.equal(continued.status, "applied", JSON.stringify(continued)); + // A historical adoption receipt cannot certify work after facts have changed. + assert.equal((await executeTodoContinuation(f.store, request)).ok, false); + assert.equal((await f.store.readReceipt("adopt")).status, "found"); + }); +} + +test("leased preparation requires exact proof; context cannot inject operational proof", async t => { + const f = await fixture(t, "file"); + for (const proof of [null, {...f.sourceProof, expected_version: 999}, {...f.sourceProof, idempotency_key: "wrong"}]) { + assert.equal((await executeTodoContinuation(f.store, {...f.prepare, lease_proof: proof})).ok, false); + } + await assert.rejects(executeTodoContinuation(f.store, {...f.prepare, lease_proof: {idempotency_key: "partial"}}), /lease_proof/); + await assert.rejects(executeTodoContinuation(f.store, {...f.prepare, + context: {...f.prepare.context, lease_proof: f.sourceProof}}), /unknown context field/); + assert.equal((await f.store.readReceipt("prepare")).status, "missing"); +}); + +test("claim transfer never refreshes previously stale context", async t => { + const f = await fixture(t, "file"); + assert.equal((await executeTodoContinuation(f.store, f.prepare)).ok, true); + await patch(f.store, f.base.todo_id, {text: "The requirements changed before transfer"}); + const original = indexCoordinationProjection((await loaded(f.store)).head, "goal-a").todos.get(f.base.todo_id)!.note; + assert.equal((await f.transfer()).status, "applied"); + assert.equal(indexCoordinationProjection((await loaded(f.store)).head, "goal-a").todos.get(f.base.todo_id)!.note, original); + assert.equal((await f.inspect()).note_state, "stale"); + assert.equal((await executeTodoContinuation(f.store, await f.adopt())).ok, false); +}); + +test("adoption seals intent, recovers lost acknowledgement, and refuses a released lease on replay", async t => { + const f = await fixture(t, "sqlite"); + assert.equal((await executeTodoContinuation(f.store, f.prepare)).ok, true); + assert.equal((await f.transfer()).status, "applied"); + const request = await f.adopt(); + const commit = f.store.commitAuthority.bind(f.store); + f.store.commitAuthority = async input => { await commit(input); throw new Error("lost response after commit"); }; + const recovered = await executeTodoContinuation(f.store, request); + assert.equal(recovered.ok, true, JSON.stringify(recovered)); + assert.equal((recovered.adoption as JsonObject).status, "recovered"); + f.store.commitAuthority = commit; + const collision = await executeTodoContinuation(f.store, {...request, session_id: "another-receiver-session"}); + assert.equal((collision.adoption as JsonObject).reason_code, "coordination_operation_identity_mismatch"); + const released = await executeCanonicalTaskLeaseLifecycle(f.store, {operation: "release", goal_id: "goal-a", + todo_id: f.base.todo_id, owner: "agent-b", idempotency_key: f.receiverProof.idempotency_key, + expected_version: f.receiverProof.expected_version, registered_agents: f.base.registered_agents, now: new Date()}); + assert.equal(released.status, "applied", JSON.stringify(released)); + assert.equal((await f.inspect()).can_adopt, false); + assert.equal((await executeTodoContinuation(f.store, request)).ok, false); + assert.equal((await f.store.readReceipt("adopt")).status, "found"); +}); + +test("concurrent canonical edits cannot be hidden by receipt-only adoption", async t => { + const f = await fixture(t, "file"); + assert.equal((await executeTodoContinuation(f.store, f.prepare)).ok, true); + assert.equal((await f.transfer()).status, "applied"); + const request = await f.adopt(); + const commit = f.store.commitAuthority.bind(f.store); + f.store.commitAuthority = async input => { + f.store.commitAuthority = commit; + await patch(f.store, f.base.todo_id, {text: "Concurrent requirements"}); + return commit(input); + }; + assert.equal((await executeTodoContinuation(f.store, request)).ok, false); + assert.equal((await f.store.readReceipt("adopt")).status, "missing"); +}); + + +for (const provider of ["file", "sqlite"]) test(`${provider}: public CLI delivers prepared and adopted current context`, async t => { + const f = await fixture(t, provider); + const state = join(f.root, "ACTIVE_GOAL_STATE.md"), registry = join(f.root, "registry.json"); + await writeFile(state, "---\ngoal_id: goal-a\n---\n\n# Workspace\n\n## Agent Todo\n"); + await writeFile(registry, JSON.stringify({common_runtime_root: f.root, goals: [{id: "goal-a", repo: f.root, + state_file: "ACTIVE_GOAL_STATE.md", coordination: {agent_model: "peer_v1", handoff_mode: "hard_lease", + registered_agents: ["agent-a", "agent-b"]}}]})); + const fence = await engageLegacyCoordinationWriterFence({schema_version: "loopx_legacy_coordination_writer_fence_engage_request_v0", + runtime_root: f.root, goal_id: "goal-a", state_path: state, + fence: {schema_version: "loopx_legacy_coordination_writer_fence_v0", state: "engaged", goal_id: "goal-a", + fence_id: "fixture", source_version: "fixture", source_projection_sha256: "sha256:fixture", + expected_shadow_provider_revision: "fixture"}}); + assert.equal(fence.status, "applied", JSON.stringify(fence)); + const run = async (args: string[]) => { + const {stdout} = await executeFile(PYTHON, ["-m", "loopx.cli", "--registry", registry, + "--runtime-root", f.root, "--format", "json", ...args], + {env: {...process.env, PYTHONPATH: process.cwd()}, timeout: 60000, maxBuffer: 4 * 1024 * 1024}); + return JSON.parse(stdout); + }; + const handoff = (action: string, agent: string, session: string, extra: string[] = []) => run([ + "handoff", action, "--goal-id", "goal-a", "--todo-id", f.base.todo_id, + "--agent-id", agent, "--session-id", session, "--workspace", f.root, ...extra]); + const sourceArgs = ["--task-lease-idempotency-key", f.sourceProof.idempotency_key, + "--task-lease-expected-version", String(f.sourceProof.expected_version)]; + const prepareArgs = [...sourceArgs, "--operation-id", "cli-prepare", + "--expected-revision", String(f.prepare.expected_provider_revision), "--rationale", "Continue with the preserved decision"]; + const pending = await handoff("prepare", "agent-a", "source", prepareArgs); + assert.equal(pending.ok, true); // Missing local display declaration cannot erase the canonical write. + assert.equal(pending.projection_delivery, "pending"); + assert.equal(pending.projection_outbox.retry_business_mutation, false); + const committed = await loaded(f.store); + const declarations = join(f.root, "goals", "goal-a", "todo-validation-declarations"); + await mkdir(declarations, {recursive: true}); + await writeFile(join(declarations, `${f.base.todo_id}.json`), JSON.stringify({ + schema_version: "loopx_todo_completion_validation_declaration_v0", goal_id: "goal-a", todo_id: f.base.todo_id, + declaration: PRODUCTION_SCALE_VALIDATION_DECLARATION, + declaration_sha256: canonicalAuthoritySha256(PRODUCTION_SCALE_VALIDATION_DECLARATION)})); + const prepared = await handoff("prepare", "agent-a", "source", prepareArgs); + assert.equal(prepared.ok, true); + assert.equal((await loaded(f.store)).provider_revision, committed.provider_revision); + assert.ok(["delivered", "current"].includes(prepared.projection_delivery), JSON.stringify(prepared)); + const projected = async () => { + const {stdout} = await executeFile(PYTHON, ["-c", + "import json,sys; from pathlib import Path; from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source; rows=parse_todo_source(Path(sys.argv[1]).read_text())[0]['agent']; print(json.dumps(next(r for r in rows if r['todo_id']==sys.argv[2])))", + state, f.base.todo_id], {env: {...process.env, PYTHONPATH: process.cwd()}}); + return JSON.parse(stdout); + }; + assert.equal(JSON.parse((await projected()).note).rationale, "Continue with the preserved decision"); + const transfer = await run(["task-lease", "transfer", "--goal-id", "goal-a", "--todo-id", f.base.todo_id, + "--owner", "agent-a", "--idempotency-key", f.sourceProof.idempotency_key, + "--expected-version", String(f.sourceProof.expected_version), "--new-owner", "agent-b", + "--new-idempotency-key", f.receiverProof.idempotency_key, "--ttl-seconds", "2700", "--transfer-claim"]); + assert.equal(transfer.ok, true, JSON.stringify(transfer)); + const receiverArgs = ["--task-lease-idempotency-key", f.receiverProof.idempotency_key, + "--task-lease-expected-version", String(f.receiverProof.expected_version)]; + const inspect = await handoff("inspect", "agent-b", "receiver", receiverArgs); + assert.equal(inspect.can_adopt, true, JSON.stringify(inspect)); + const beforeInspect = await readFile(state, "utf8"); + await handoff("inspect", "agent-b", "receiver", receiverArgs); + assert.equal(await readFile(state, "utf8"), beforeInspect); + const adopted = await handoff("adopt", "agent-b", "receiver", [...receiverArgs, + "--operation-id", "cli-adopt", "--expected-revision", inspect.provider_revision]); + assert.equal(adopted.current_authority_verified, true, JSON.stringify(adopted)); + assert.ok(["delivered", "current"].includes(adopted.projection_delivery)); + assert.equal((await projected()).claimed_by, "agent-b"); +}); + + +for (const blocker of ["expired", "acceptance"]) test(`current ${blocker} hold defeats a historical adoption receipt`, async t => { + const f = await fixture(t, "file"); + assert.equal((await executeTodoContinuation(f.store, f.prepare)).ok, true); + assert.equal((await f.transfer()).status, "applied"); + const request = await f.adopt(); + assert.equal((await executeTodoContinuation(f.store, request)).ok, true); + const head = await loaded(f.store); + if (blocker === "acceptance") { + const result = await configureGoalAcceptance(f.store, {goal_id: "goal-a", operation_id: "acceptance-hold", + actor_agent_id: null, expected_provider_revision: head.provider_revision, + document: {objective: "Validate the selected work", non_goals: [], + scope: {kind: "selected_work", todo_ids: [f.base.todo_id]}, + criteria: [{id: "check", description: "Independent check passes", validation_argv: ["true"], validation_timeout_seconds: 10}], + bindings: []}}); + assert.equal(result.status, "applied", JSON.stringify(result)); + } else { + const lease = indexCoordinationProjection(head.head, "goal-a").leases.get(f.base.todo_id)!; + assert.equal((await f.store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: "goal-a", + operation_id: "expire", expected_provider_revision: head.provider_revision, projection: head.head, + mutations: [{kind: "lease_upsert", lease: {...lease, expires_at: "2020-01-01T00:00:00Z"}}]}))).status, "applied"); + } + assert.equal((await f.inspect()).can_adopt, false); + const rejected = await executeTodoContinuation(f.store, request); + assert.equal(rejected.ok, false); + if (blocker === "acceptance") assert.equal(rejected.reason_code, "goal_acceptance_unbound"); + assert.equal((await f.store.readReceipt("adopt")).status, "found"); +}); diff --git a/tests/control_plane_ts/test_python_runtime.test.ts b/tests/control_plane_ts/test_python_runtime.test.ts index 86897ff9c1..4048bf153d 100644 --- a/tests/control_plane_ts/test_python_runtime.test.ts +++ b/tests/control_plane_ts/test_python_runtime.test.ts @@ -81,6 +81,14 @@ test("test and browser smokes may not introduce bare python or python3 subproces // incompatible interpreter), so direct launches, fallbacks and assigned // defaults must both route through resolveTestPython(). const direct = /\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*["']python3?["']/; + // A promisified or aliased launcher starts the same bare alias, so the guard + // must see through the wrapper instead of accepting the indirection. + const launchers = "spawn|spawnSync|execFile|execFileSync"; + const promisified = new RegExp(`\\b(?:promisify|util\\s*\\.\\s*promisify)\\s*\\(\\s*(?:${launchers})\\s*\\)\\s*\\(\\s*["']python3?["']`); + const aliasBinding = new RegExp(`\\b(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:promisify|util\\s*\\.\\s*promisify)\\s*\\(\\s*(?:${launchers})\\s*\\)`, "g"); + const aliasedLaunch = (source: string): boolean => [...source.matchAll(aliasBinding)] + .map(match => new RegExp(`\\b${match[1]}\\s*\\(\\s*["']python3?["']`)) + .some(pattern => pattern.test(source)); const fallback = /(?:\?\?|\|\|)\s*["']python3?["']/; const assigned = /\b(?:const|let)\s+\w+\s*=\s*["']python3?["']/; const bare = JSON.stringify("python3"); @@ -88,6 +96,10 @@ test("test and browser smokes may not introduce bare python or python3 subproces assert.ok(direct.test(`spawn(${bare}, ["-m", "loopx.cli"])`)); assert.ok(direct.test(`spawn(${barePython}, ["-m", "loopx.cli"])`)); assert.ok(direct.test(`spawnSync(${barePython}, ["-c", "raise SystemExit(0)"])`)); + assert.ok(promisified.test(`promisify(execFile)(${bare}, ["-m", "loopx.cli"])`)); + assert.ok(promisified.test(`util.promisify(execFileSync)(${barePython}, [])`)); + assert.ok(aliasedLaunch(`const run = promisify(execFile);\nrun(${bare}, ["-m", "loopx.cli"]);`)); + assert.ok(aliasedLaunch(`const run = util.promisify(spawnSync);\nawait run(${barePython}, []);`)); assert.ok(fallback.test(`process.env.LOOPX_TEST_PYTHON ?? ${bare}`)); assert.ok(fallback.test(`process.env.LOOPX_TEST_PYTHON ?? ${barePython}`)); assert.ok(fallback.test(`process.env.NEW_TEST_PYTHON || ${bare}`)); @@ -97,6 +109,10 @@ test("test and browser smokes may not introduce bare python or python3 subproces assert.ok(assigned.test(`const testInterpreter = ${bare}`)); assert.equal(direct.test(`validation_command_argv: [${bare}, "-m", "pytest"]`), false); assert.equal(direct.test(`validation_command_argv: [${barePython}, "-m", "pytest"]`), false); + assert.equal(promisified.test(`promisify(execFile)(${JSON.stringify("/usr/bin/python3")}, [])`), false); + // A resolved interpreter or an unrelated helper argument is not a launch. + assert.equal(aliasedLaunch(`const run = promisify(execFile);\nrun(PYTHON, ["-m", "loopx.cli"]);`), false); + assert.equal(direct.test(`qualificationHelperArgv(${bare})`), false); // An absolute path or a versioned executable is a resolved interpreter, not a bare alias. assert.equal(direct.test(`spawnSync(${JSON.stringify("/usr/bin/python3")}, [])`), false); assert.equal(assigned.test(`const executable = ${JSON.stringify("/opt/loopx-qualification/bin/python")}`), false); @@ -106,7 +122,8 @@ test("test and browser smokes may not introduce bare python or python3 subproces if (entry.isDirectory()) inspect(path); else if (/\.(?:cjs|js|mjs|mts|ts)$/.test(entry.name)) { const source = readFileSync(join(root, path), "utf8"); - if (direct.test(source) || fallback.test(source) || assigned.test(source)) offenders.push(path); + if (direct.test(source) || promisified.test(source) || aliasedLaunch(source) + || fallback.test(source) || assigned.test(source)) offenders.push(path); } } } diff --git a/tests/control_plane_ts/todo_continuation.test.ts b/tests/control_plane_ts/todo_continuation.test.ts index af02ebcbe5..506480d5d4 100644 --- a/tests/control_plane_ts/todo_continuation.test.ts +++ b/tests/control_plane_ts/todo_continuation.test.ts @@ -19,7 +19,7 @@ import {resolveTestPython} from "../../scripts/test-python.mjs"; const PYTHON = resolveTestPython(); -async function fixture(t: test.TestContext) { +async function fixture(t: test.TestContext, mode = "soft_claim") { const root = await mkdtemp(join(tmpdir(), "loopx-continuation-")); t.after(() => rm(root, {recursive: true, force: true})); const directory = join(root, "authority", "file-v0"); @@ -27,7 +27,7 @@ async function fixture(t: test.TestContext) { const todos = [{schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: "todo_a", role: "agent", status: "open", done: false, text: "Validate the selected implementation", archive_state: "active", claimed_by: "agent-a"}]; await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, events: [], receipts: [], - next_projection: {goal_id: "goal-a", handoff_mode: "soft_claim", todos, leases: [], todo_read_model: { + next_projection: {goal_id: "goal-a", handoff_mode: mode, todos, leases: [], todo_read_model: { schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, todo_count: 1, records_sha256: canonicalAuthoritySha256(todos), contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}}}); const base = {goal_id: "goal-a", todo_id: "todo_a", agent_id: "agent-a", registered_agents: ["agent-a", "agent-b"], @@ -877,3 +877,13 @@ test("P1: extra nested key in approach_tried rejected by final claim decision", assert.equal(decision.status, "rejected", JSON.stringify(decision)); assert.equal(decision.reason_code, "claim_owner_mismatch", JSON.stringify(decision)); }); + + +test("hard-lease inspect reads context without pretending an unfenced receiver can adopt", async t => { + const f = await fixture(t, "hard_lease"); + const packet = await f.inspect(); + assert.equal(packet.ok, true, JSON.stringify(packet)); + assert.equal(packet.can_adopt, false); + assert.equal(packet.note_state, "missing"); + assert.equal((packet.execution_authority as Record).allowed, false); +});