diff --git a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md index fdb2739efba..a306876a3e8 100644 --- a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md @@ -46,8 +46,8 @@ | `managed-dependency-environment`、bundled npm、worker bridge | M1/M1.3 独立前置;不从旧 M2 分支重复携带 | | Git candidate ref/commit、delta/path policy、orphan GC | M2.2 | | T1 mutation profile/base freeze、durable workspace reservation、accepted path truth | M2.3a | -| owner-bound mutating admission、reopen active-reservation gate | M2.3b | -| 真实 Write/Edit composition、Host crash/reopen | M2.4 | +| Runtime managed settlement fail-stop、exact durable response adoption | M2.3b | +| owner-bound mutation profile/worker、active-reservation admission、真实 Write/Edit composition | M2.4 | | continuation boundary 绑定 workspace version | M3 | | restore、rebaseline、publish、undo、replication | M4 | @@ -63,8 +63,20 @@ | storage/core focused tests | schema、rollback、rebuild、真实 crash、双进程 exclusivity | | `runtime-managed-workspace-mutation-reservation-v1.zh-CN.md` | owner、原子边界、失败状态与平台矩阵 | -M2.3a 不迁入 Runtime hook、ManagedWorkspaceOwner lease、candidate capture/discard 或真实 Write/Edit;前两项属于 -M2.3b,后两项属于 M2.4。 +M2.3a 不迁入 Runtime hook、ManagedWorkspaceOwner mutation lease、candidate capture/discard 或真实 Write/Edit; +Runtime settlement hook 属于 M2.3b,其余三项由 M2.4 的真实 owner/consumer 一次性闭环。 + +## M2.3b 增量归属 + +| 路径 | 归属 | 不变量 | +|---|---|---| +| `packages/runtime/src/tool-runtime.ts` | M2.3b | managed T1 后整个 normalization/adoption/publication seam fail-stop;只采用 exact durable envelope | +| `packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts` | M2.3b | pre-T1 Host seam、success/discard adoption、size/canonicalization/envelope/unsettled fail-stop | +| `docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md` | M2.3b | Runtime owner、失败状态、平台证据和 M2.4 seam | + +M2.3b 不迁入 `ManagedWorkspaceOwner` mutation lease/profile、built-in Write/Edit 标记、filesystem worker mutation、 +Git candidate capture/discard、successor/error bundle composition 或 Desktop/CLI wiring;这些共同构成 M2.4 的首个 +生产消费者。只读 worker 的存在不能签发 mutation profile,caller callback 也不能自证执行能力。 ## Commit 映射 | 当前重建内容 | 来源 | 说明 | @@ -72,6 +84,7 @@ M2.3b,后两项属于 M2.4。 | core RED tests + causal successor facts | `14327f5f0` 的 core 最终状态 | 只保留 subpath contract | | storage RED tests + atomic successor writer | `14327f5f0` 的 storage 最终状态 | 在 current-main schema 12 上新增 migration 13 | | reservation RED tests + T1 owner projection | `36e1e3ec4` | 从 M2.2 stack 中只提取 M2.3a 自有差异,跳过旧 Git CLI candidate owner | +| Runtime settlement RED tests + fail-stop owner | `584fe099d..3825677aa` 的 Runtime 净差异 | 不迁入旧 recovery inventory、Host 或 Git candidate 路径 | | 文档与 extraction ledger | 现行工程实践 | 记录最新 main、Gitoxide 换轨和 M2.2–M2.4 等待关系 | ## Diff 审核门槛 diff --git a/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md new file mode 100644 index 00000000000..a6b6cc6b309 --- /dev/null +++ b/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md @@ -0,0 +1,163 @@ + + +# Managed Workspace Mutation Runtime Settlement v1 + +- 阶段:M2.3b +- 状态:实现切片;保持 Draft,等待 M2.4 Write/Edit 生产消费者 +- owner:Tool Runtime managed settlement seam +- durable 真相:M2.3a SQLite reservation 与 owner 已提交的 immutable outcome + +## 1. 本切片只证明一个主要不变量 + +当未来的 Host owner 在 T1 前返回一份 exact `managed_mutation_v1` dispatch 后,Tool Runtime 将它与 call 一起 +提交为 durable T1。从这一刻起,整个 settlement、result canonicalization、大小检查、durable outcome adoption、 +provider publication 与 telemetry 都处于 managed fail-stop 状态:任何异常都不得进入 generic synthetic T2。 + +managed T1 后的 operation authority 是 Runtime-owned 线性 capability:`open → running → settled → closed`。 +owner 最多调用一次;`execute()` 返回或抛错时 capability 立即关闭。若 owner 在 operation 尚未完成时提前结算, +Runtime 必须先 join 该 operation,再将整个结算判为 unsettled,确保 Runtime 返回后不存在仍可产生副作用的 detached +execution。 + +Runtime 只接受三种 owner 结算: + +1. `workspace_successor_committed`:M2.1 的成功 T2、successor 与 head 已经原子提交; +2. `safely_discarded`:owner 已证明 candidate 未被接受,并已提交 exact error outcome; +3. `unsettled`:副作用或结算状态不可证明,M2.3a reservation 保留给恢复流程。 + +owner 返回值首先经过运行时结构校验,规范化成内部 terminal union。managed/generic lane 只由 T1 前已经确定的 +`managedMutationAdmission` 决定,绝不再用 `durableOutcome` 是否 truthy 选择 writer;terminal settlement 缺失 +durable outcome 或 canonical content 时只能 fail-stop。成功路径的 provider value 始终由 Runtime 在执行 +`operation()` 时捕获并冻结,不进入 owner settlement。 + +本切片不声称拥有 mutation worker、execution-profile attestation、Git candidate 或 production admission。它只定义 +Runtime 如何消费 M2.4 将提供的 owner capability。这样 T1 不会记录一个由只读 worker 或 caller callback 冒充的 +execution profile。 + +## 2. 权威分层 + +| 层 | owner | 当前职责 | +|---|---|---| +| durable ownership | M2.3a SQLite authority | T1 时创建跨进程 reservation,successor 时原子消费 | +| runtime settlement | M2.3b Tool Runtime | T1 后禁止 generic T2;采用完整一致的 durable response envelope | +| execution admission | M2.4 Host/workspace owner | 绑定真实 mutation worker、sandbox profile、head、paths 与 candidate lease | +| production composition | M2.4 | 执行 Write/Edit、capture/discard candidate、提交 successor/error | + +`admitManagedMutation` 在 M2.3b 是一个注入式 Host seam,不是当前 `ManagedWorkspaceOwner` 的公开能力。没有该 seam +时,标记为 `managed_mutation_v1` 的工具必须在 T1 前拒绝;不得降级为普通工具执行。 + +## 3. T1 前接口边界 + +```text +Tool Runtime + -> Host admission seam(operation, tool, args) + -> M2.4: validate real worker/profile/head/path/candidate capability + -> return exact managedMutation dispatch + one-shot execute/dispose handle + -> SQLite commitToolPrepared(call + dispatch + reservation) +``` + +M2.3b 只校验 Host seam 是否存在,并将其返回的 immutable dispatch 原样纳入 T1。dispatch 的真实性、profile +digest 如何从实际 worker/sandbox 能力产生,以及 admission 如何重验 canonical head,全部由 M2.4 同一个 owner +实现和测试。 + +## 4. T1 后 fail-stop 状态机 + +```mermaid +flowchart TD + T1["managed T1 durable"] --> O["Runtime executes and owns provider value"] + O --> Q["owner receives isolated outcome proof"] + Q --> N["normalize terminal proof"] + N --> S{"terminal state proven?"} + S -->|"successor committed"| A["compare exact durable success envelope"] + S -->|"safely discarded"| E["compare exact durable error envelope"] + S -->|"unknown / throw / invalid"| P["fail-stop; reservation remains"] + A --> R["publish provider result"] + E --> R +``` + +`safely_discarded` 只携带一个 exact `providerResult`。Runtime 从该值生成 canonical content,并执行与普通工具 +相同的 `maxResultBytes` 检查。getter、serialization、canonicalization 或 size-check 的任何失败都变成 managed +unsettled,不写 generic T2。 + +`workspace_successor_committed` 只携带 `durableOutcome`,不得重新提交 provider value。Runtime 在调用真实 operation +完成边界用一次 bounded strict-JSON walker 同时执行 byte budget、类型校验、plain snapshot 和递归冻结。walker +一旦超限立即停止,不会先复制完整对象;并拒绝 `undefined`、非有限数字、稀疏数组、accessor、cycle、`toJSON`、 +非 plain container 及其他非 JSON 值。canonical content、proof、message、telemetry 和 provider 返回只能读取该 +snapshot。 +Runtime 私有保存 snapshot,仅把隔离复制的 `content/isError/durationMs` proof 交给 owner 用于提交 successor。owner +返回后,Runtime 用自己的 canonical outcome 校验 durable envelope;因此 owner 无法构造 live A / replay B,工具也 +无法通过事后修改原始返回对象改变已验证结果。 + +owner 返回的 response 必须与 Runtime 从 T1 identity 构造的唯一 canonical envelope 完全相等,包括:event id、 +run/invocation/session/turn、timestamp 形状、origin、model visibility、function response、error bit、exact refs、 +parent refs 和 duration。缺字段、多字段或任意值不同都 fail-stop。这样 live provider 与 crash replay 只有一个结果源。 + +## 5. 失败状态 + +| 场景 | 结果 | +|---|---| +| Host admission seam 缺失或 admission 在 T1 前失败 | 无 T1、不执行工具 | +| T1 commit 失败 | dispose admission,不执行工具 | +| owner 已原子接受 successor | Runtime 采用 exact durable success outcome | +| owner 已安全 discard | Runtime 采用 exact durable error outcome | +| owner 返回 unsettled、抛错或响应丢失 | 无 generic T2、无 provider result,reservation 保留 | +| owner 结算后再次调用 operation | capability 已关闭,不执行工具 | +| owner 在 operation 运行中提前结算 | join operation 后 fail-stop,不接受 terminal settlement | +| canonicalization、size check、envelope validation 或 publication 失败 | 同上,统一 fail-stop | +| durable envelope origin/visibility/refs/duration 不一致 | T2 boundary error,禁止发布 | + +## 6. 平台能力矩阵 + +| 能力 | Linux | macOS | Windows | +|---|---|---|---| +| T1 reservation kill/reopen | 历史 CI 已证明;重建分支待 CI | 历史 CI 已证明;重建分支待 CI | 本地定向通过;重建分支待 CI | +| 跨进程唯一 mutation reservation | 同上 | 同上 | 同上 | +| Runtime managed settlement fail-stop | 平台无关测试 | 平台无关测试 | 平台无关测试 | +| mutation worker/profile/candidate | M2.4 | M2.4 | M2.4 | +| power-loss convergence | 不在本切片 | 不在本切片 | 不在本切片 | + +本次平铺重建保留 `sqlite-runtime-crash` 与 `sqlite-recovery-concurrency` 的真实进程用例,但不从等待 +Gitoxide 重建的 M2.2 携带旧 candidate inventory。最终发布 CI inventory 在 M2.2/M2.4 重建时统一登记。 + +## 7. 验证 + +- Host admission 缺失时不落 T1、不执行工具; +- owner-committed successor 只采用 durable T2,不调用 generic writer; +- success settlement 缺失 durable outcome 时不调用 generic writer; +- success settlement 不能替换 Runtime-owned provider value,live 与 replay 内容不一致时 fail-stop; +- terminal settlement 撤销 operation capability;detached operation 必须 join 后 fail-stop; +- 工具事后修改原始 result 不得改变 durable、message、event 或 provider snapshot; +- oversized result 必须在遍历越过 budget 前终止;strict-JSON 非法值不得进入 durable event; +- safe-discard live result 与 durable content 不一致时 fail-stop; +- getter/canonicalization 异常和超大 safe-discard 均不写 generic T2、不发布结果; +- code-mode response 的 origin、hidden visibility、parent refs 与 duration 必须完整匹配; +- explicit unsettled、owner throw 和 T1 后任意异常均 fail-stop; +- real-process kill-after-T1 与双进程 reservation 竞争已进入 focused suite;三平台严格 inventory 等待 + Gitoxide M2.2/M2.4 重建后统一登记。 + +## 8. 明确留给 M2.4 + +- 不给 built-in Write/Edit 启用 `durableExecutionProfile`; +- 不由 `ManagedWorkspaceOwner` 签发 mutation lease 或静态 profile digest; +- 不调用真实 mutation-capable filesystem worker; +- 不 capture、discard 或 accept Git candidate; +- 不接 Desktop/CLI,不改变当前用户可见 resume 能力。 + +M2.3b 没有生产消费者,因此即使测试通过也保持 Draft。M2.4 必须由同一个 owner 同时签发并执行真实 mutation +profile,不能重新引入 caller digest、静态标签或 callback 自证。 diff --git a/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md index b9195b6337a..5a6c21b8935 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md @@ -129,14 +129,14 @@ M2.3b/M2.4 的平台 owner 在副作用前验证。Core 不读取 `process.platf ## 8. 后续切片 -### M2.3b — Runtime admission composition +### M2.3b — Runtime settlement seam -在 T1 前取得 owner-bound capability,重验平台路径、execution profile 和 canonical head;启动时读取 active -reservation,禁止新 admission 越过 prepared operation。它不得 capture/discard Git candidate,也不得自行写 -successor。 +Runtime 接收未来 M2.4 Host owner 提供的 admission,但本切片只证明:managed T1 后不再进入 generic T2,且 live +result 只能采用与 T1 identity 完整一致的 durable response envelope。平台路径、execution profile、canonical +head 与 active-reservation admission 由 M2.4 的真实 mutation owner 统一证明。 ### M2.4 — Write/Edit production settlement -真实 Write/Edit 执行、candidate capture/verification、owner-controlled settlement 与 reservation terminal -transition 在此闭环。Runtime 必须能区分:successor 已接受、candidate 可安全 discard、effect unknown/park; -不能在 managed callback 后自动写 generic T2。 +真实 Write/Edit admission/profile、worker 执行、candidate capture/verification、owner-controlled settlement 与 +reservation terminal transition 在此闭环。Runtime 必须能区分:successor 已接受、candidate 可安全 discard、 +effect unknown/park;不能在 managed callback 后自动写 generic T2。 diff --git a/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md index 1abeec72017..6d54c29dab2 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md @@ -203,23 +203,24 @@ SQLite transaction 提供三平台一致的数据库原子性;本切片不声 - 详细合同见 [Managed Workspace Mutation Reservation v1](./runtime-managed-workspace-mutation-reservation-v1.zh-CN.md)。 -### M2.3b — Mutation execution admission +### M2.3b — Runtime managed settlement -主要不变量:T1 前冻结 base workspace head、execution profile、operation identity 和 candidate lease;T1 后禁止切换 -attached/managed mode 或退回旧直接写路径。 +主要不变量:Host admission seam 缺失时不落 T1;managed T1 durable 后,settlement、canonicalization、大小检查、 +durable outcome adoption 与 publication 的任何失败都禁止进入 generic T2。 -- owner:Runtime Host managed mutation admission; -- 原子边界:消费 M2.3a,在 T1 durable dispatch 前签发 owner-bound capability; -- 失败状态:能力缺失、scope 失效、base/head/profile 不一致均在工具副作用前拒绝; -- 回滚:T1 前失败走标准 tool error;T1 后不确定状态保持 unsettled,交给 M2.4 收敛。 +- owner:Tool Runtime managed settlement seam; +- 事实边界:只采用 owner 已提交且与 T1 identity 完整相等的 durable response envelope; +- 失败状态:invalid/unsettled/throw/lost response 均保留 M2.3a reservation并 fail-stop; +- 明确不证明:真实 mutation worker、execution profile、canonical head admission 和 candidate lease。 -该 PR 不新增第二套 workspace writer,不 capture/discard candidate,只消费 M2.1/M2.3a 的 internal capability。 +后面四项必须由 M2.4 的同一个 Host/workspace owner 同时签发并执行,不能由只读 worker、静态 digest 或 caller +callback 自证。 ### M2.4 — Write/Edit production composition 主要不变量:真实 Write/Edit 的成功只能来自“owned candidate 已验证 + M2.1 bundle 已提交”。 -- 把 owner-bound worker、candidate capture 与 successor bundle 串成唯一生产路径; +- 把 owner-bound profile/admission、mutation worker、candidate capture 与 successor bundle 串成唯一生产路径; - 增加真实 Host kill/reopen crash matrix; - 保持现有 tool result、permission、sandbox 与 cancellation 语义; - 作为 M2.2/M2.3 的首个生产消费者,完成后才允许前三个切片转 Ready/依序合并。 diff --git a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md index 993900b604e..3149c505065 100644 --- a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md +++ b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md @@ -620,10 +620,11 @@ M2 按 owner 与原子边界拆成五个 stacked slices: candidate commit/ref、exact-base CAS 与 artifact receipt;不写 T2,不恢复旧 Git CLI adapter; 3. **M2.3a durable mutation reservation(当前重建切片)**:schema 14 将 T1 与 workspace-instance-exclusive reservation 原子绑定,successor fact 固化 exact changed paths,generic T2 无权结算 managed mutation; -4. **M2.3b mutation execution admission**:T1 前冻结 base head、execution profile、operation identity, - 消费 durable reservation gate;T1 后禁止 silent fallback; -5. **M2.4 Write/Edit production composition**:把 owner-bound worker、candidate capture 与 M2.1 bundle - 串成唯一生产路径,并用真实 Host kill/reopen crash test 完成前三片的生产消费者。 +4. **M2.3b Runtime managed settlement**:Host seam 缺失时不落 T1;managed T1 后所有 normalization、 + size/envelope validation 与 publication failure 都 fail-stop,禁止 generic T2; +5. **M2.4 Write/Edit production composition**:由同一 owner 绑定真实 mutation profile、canonical head、 + active reservation、worker、candidate capture 与 M2.1 bundle,并用真实 Host kill/reopen crash test 完成前三片的 + 生产消费者。 M2.2/M2.3a/M2.3b 在 M2.4 消费者存在前保持 Draft。M2.4 不绑定 continuation boundary;该能力仍属于 M3。 @@ -661,12 +662,13 @@ M0.1 Git artifact owner (merged) └─> M0.4 baseline open bundle (merged) └─> M1.1 execution scope admission (current) └─> M1.2 runtime-host composition - └─> M1.3 explicit environment provisioning - └─> M2.1 mutation version persistence authority - └─> M2.2 Git mutation candidate owner - └─> M2.3a durable mutation reservation - └─> M2.3b mutation execution admission - └─> M2.4 Write/Edit production composition + ├─> M1.3 explicit environment provisioning + └─> M2.1 mutation version persistence authority + ├─> M2.3a durable mutation reservation + │ └─> M2.3b mutation execution admission + └─> Gitoxide source import + candidate/ref data plane + └─> M2.2 Gitoxide mutation candidate owner + └─> M2.4 Write/Edit production composition └─> M3 workspace-bound continuation └─> M4 restore / rebaseline / publish / replication diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index c6c216dd81a..e67c74e8fca 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -29,7 +29,12 @@ import type { ToolOutcomeCommit, ToolPreparedCommit, } from '../runtime-commit-sink.js'; -import { ToolRuntime, type MakaTool } from '../tool-runtime.js'; +import { + ToolRuntime, + type MakaTool, + type RuntimeManagedMutationAdmission, + type ToolRuntimeInput, +} from '../tool-runtime.js'; describe('ToolRuntime durable boundary', () => { it('does not invoke the tool or publish a result when T1 fails', async () => { @@ -197,6 +202,904 @@ describe('ToolRuntime durable boundary', () => { assert.equal(outcomes[0]?.runtimeEvent.refs?.operationId, prepared[0]?.operationId); }); + it('adopts an owner-committed managed successor without invoking generic T2', async () => { + const order: string[] = []; + const prepared: ToolPreparedCommit[] = []; + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async (input) => { + prepared.push(input); + order.push('t1'); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + order, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + order.push('admit'); + return managedAdmission(async (operation) => { + order.push('lease-enter'); + const proof = await operation(); + order.push('successor-bundle'); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }, order); + }, + }, + ); + const managedTool = tool(() => { + order.push('impl'); + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { ok: true }); + assert.deepEqual(order, [ + 'admit', + 't1', + 'lease-enter', + 'impl', + 'successor-bundle', + 'published-result', + 'dispose', + ]); + assert.deepEqual( + prepared[0]?.dispatchRuntimeEvent.actions?.toolDispatch?.managedMutation, + managedMutationDispatch(), + ); + }); + + it('does not replace a committed managed result when admission cleanup fails', async () => { + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return { + durableDispatch: managedMutationDispatch(), + execute: async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }, + dispose: async () => { + throw new Error('cleanup failed after commit'); + }, + }; + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { ok: true }); + }); + + it('leaves a managed T1 unsettled without publishing or writing generic T2', async () => { + let genericOutcomeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + return { kind: 'unsettled', error: new Error('candidate state is unknown') }; + }), + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /candidate state is unknown/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + assert.equal( + harness.messages.some((message) => message.type === 'tool_result'), + false, + ); + }); + + it('fail-stops a thrown managed settlement instead of falling back to generic T2', async () => { + let genericOutcomeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + throw new Error('owner settlement channel failed'); + }), + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /owner settlement channel failed/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('fail-stops a managed success with no durable outcome instead of writing generic T2', async () => { + let genericOutcomeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + return { + kind: 'workspace_successor_committed', + } as never; + }), + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /durable outcome/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('does not let a managed owner replace the Runtime-owned success result', async () => { + let genericOutcomeCalls = 0; + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + const proof = await operation(); + const forgedContent = { kind: 'json' as const, value: { source: 'durable-B' } }; + return { + kind: 'workspace_successor_committed', + // Simulate an untyped/older Host attempting to reintroduce the + // removed result channel. Runtime must ignore this value and + // compare the durable event with its own captured operation. + value: { + result: { source: 'live-A' }, + outcome: { + content: forgedContent, + isError: false, + durationMs: proof.durationMs, + }, + }, + durableOutcome: managedOutcomeEvent(operationId, forgedContent, false, { + durationMs: proof.durationMs, + }), + } as never; + }); + }, + }, + ); + const managedTool = tool(() => ({ source: 'runtime-original' })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /mismatched durable outcome/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('publishes one immutable snapshot when the tool mutates its returned object later', async () => { + let operationId = ''; + const mutableResult = { state: 'A' }; + const appendedMessages: StoredMessage[] = []; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + appendMessage: async (message) => { + if (message.type === 'tool_result') mutableResult.state = 'B'; + appendedMessages.push(structuredClone(message)); + }, + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => mutableResult); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const result = await harness.execute(managedTool); + const storedResult = appendedMessages.find((message) => message.type === 'tool_result'); + const liveEvent = harness.events.find((event) => event.type === 'tool_result'); + + assert.equal(mutableResult.state, 'B'); + assert.deepEqual(result, { state: 'A' }); + assert.equal(Object.isFrozen(result), true); + assert.deepEqual(storedResult?.type === 'tool_result' ? storedResult.content : undefined, { + kind: 'json', + value: { state: 'A' }, + }); + assert.deepEqual(liveEvent?.type === 'tool_result' ? liveEvent.content : undefined, { + kind: 'json', + value: { state: 'A' }, + }); + }); + + it('preserves JSON __proto__ keys as immutable data properties', async () => { + let operationId = ''; + const resultWithProtoKey = Object.create(null) as Record; + Object.defineProperty(resultWithProtoKey, '__proto__', { + enumerable: true, + value: { safe: true }, + }); + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => resultWithProtoKey); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const result = (await harness.execute(managedTool)) as Record; + + assert.equal(Object.hasOwn(result, '__proto__'), true); + assert.deepEqual(result.__proto__, { safe: true }); + assert.equal(Object.isFrozen(result.__proto__), true); + assert.equal(JSON.stringify(result), '{"__proto__":{"safe":true}}'); + }); + + it('adopts an owner-committed safe discard without invoking generic T2', async () => { + let genericOutcomeCalls = 0; + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + await operation(); + const result = { error: 'candidate was safely discarded' }; + return { + kind: 'safely_discarded', + providerResult: result, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: result }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + error: 'candidate was safely discarded', + }); + assert.equal(genericOutcomeCalls, 0); + const published = harness.events.at(-1); + assert.equal(published?.type, 'tool_result'); + assert.equal(published?.type === 'tool_result' && published.isError, true); + }); + + it('snapshots a safe-discard result before its owner can mutate it', async () => { + let operationId = ''; + const ownerResult = { error: 'discarded-A' }; + const appendedMessages: StoredMessage[] = []; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + appendMessage: async (message) => { + if (message.type === 'tool_result') ownerResult.error = 'mutated-B'; + appendedMessages.push(structuredClone(message)); + }, + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + await operation(); + return { + kind: 'safely_discarded', + providerResult: ownerResult, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: { error: 'discarded-A' } }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const result = await harness.execute(managedTool); + const storedResult = appendedMessages.find((message) => message.type === 'tool_result'); + + assert.equal(ownerResult.error, 'mutated-B'); + assert.deepEqual(result, { error: 'discarded-A' }); + assert.equal(Object.isFrozen(result), true); + assert.deepEqual(storedResult?.type === 'tool_result' ? storedResult.content : undefined, { + kind: 'json', + value: { error: 'discarded-A' }, + }); + }); + + it('revokes a retained managed operation after terminal settlement', async () => { + let operationId = ''; + let implementationCalls = 0; + let retainedOperation: Parameters[0] | undefined; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + retainedOperation = operation; + const result = { error: 'candidate was safely discarded' }; + return { + kind: 'safely_discarded', + providerResult: result, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: result }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => { + implementationCalls += 1; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + error: 'candidate was safely discarded', + }); + assert.ok(retainedOperation); + await assert.rejects(retainedOperation(), /operation capability is closed/i); + assert.equal(implementationCalls, 0); + }); + + it('does not accept terminal settlement while a detached operation is running', async () => { + let operationId = ''; + let releaseOperation!: () => void; + const operationBlocked = new Promise((resolve) => { + releaseOperation = resolve; + }); + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + void operation().catch(() => undefined); + const result = { error: 'candidate was safely discarded' }; + return { + kind: 'safely_discarded', + providerResult: result, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: result }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(async () => { + await operationBlocked; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const execution = harness.execute(managedTool); + const settledBeforeRelease = await Promise.race([ + execution.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(() => resolve(false), 20)), + ]); + releaseOperation(); + + assert.equal(settledBeforeRelease, false); + await assert.rejects(execution, /owner settled before the operation completed/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('rejects a safe discard whose live error differs from its durable result', async () => { + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + await operation(); + return { + kind: 'safely_discarded', + providerResult: { error: 'live provider error A' }, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: { error: 'durable replay error B' } }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /mismatched durable outcome/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('fail-stops safe-discard canonicalization without writing generic T2', async () => { + let genericOutcomeCalls = 0; + let operationId = ''; + const providerResult = Object.defineProperty({}, 'kind', { + enumerable: true, + get: () => { + throw new Error('provider result getter exploded'); + }, + }); + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + await operation(); + return { + kind: 'safely_discarded', + providerResult, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: { error: 'discarded' } }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects( + harness.execute(managedTool), + /strict JSON.*accessor|provider result getter exploded|byte limit exceeded/i, + ); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('fail-stops an oversized safe discard before durable publication', async () => { + let genericOutcomeCalls = 0; + let operationId = ''; + const oversized = { error: 'x'.repeat(128) }; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + await operation(); + return { + kind: 'safely_discarded', + providerResult: oversized, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: oversized }, + true, + { + origin: 'code_mode', + modelVisibility: 'hidden', + toolCallId: 'nested-call-1', + parentToolCallId: 'exec-1', + parentOperationId: 'exec-op-1', + }, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.executeNested(managedTool, 32), /byte limit exceeded/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal(JSON.stringify(harness.events).includes(oversized.error), false); + }); + + it('stops snapshot traversal as soon as a managed result exceeds its byte budget', async () => { + let genericOutcomeCalls = 0; + let lateGetterReads = 0; + const oversizedResult = { payload: 'x'.repeat(128) } as Record; + Object.defineProperty(oversizedResult, 'mustNotBeRead', { + enumerable: true, + get: () => { + lateGetterReads += 1; + throw new Error('snapshot walked past its byte budget'); + }, + }); + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + return { kind: 'unsettled', error: new Error('unreachable') }; + }), + }, + ); + const managedTool = tool(() => oversizedResult); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects( + harness.executeNested(managedTool, 32), + /tool result byte limit exceeded/i, + ); + assert.equal(lateGetterReads, 0); + assert.equal(genericOutcomeCalls, 0); + }); + + it('rejects undefined fields instead of creating a non-canonical durable result', async () => { + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true, missing: undefined })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /strict JSON.*undefined/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + for (const [description, makeResult, expectedError] of [ + ['non-finite numbers', () => ({ n: Number.NaN }), /strict JSON.*not finite/i], + ['undefined array entries', () => ({ list: [undefined] }), /strict JSON.*undefined/i], + ['sparse arrays', () => ({ list: new Array(1) }), /strict JSON.*sparse array/i], + ] as const) { + it(`rejects ${description} before accepting a managed durable result`, async () => { + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => makeResult()); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), expectedError); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + } + + it('rejects a durable managed response with a different code-mode envelope', async () => { + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + origin: 'code_mode', + // The live nested call is hidden. A visible durable replay is + // a different provider contract and must never be adopted. + modelVisibility: 'visible', + toolCallId: 'nested-call-1', + parentToolCallId: 'exec-1', + parentOperationId: 'exec-op-1', + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.executeNested(managedTool), /mismatched durable outcome/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('refuses a managed mutation before T1 when host admission is unavailable', async () => { + let preparedCalls = 0; + let implementationCalls = 0; + const harness = makeHarness({ + commitToolPrepared: async () => { + preparedCalls += 1; + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }); + const managedTool = tool(() => { + implementationCalls += 1; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + error: 'Managed workspace mutation admission is unavailable before T1', + }); + assert.equal(preparedCalls, 0); + assert.equal(implementationCalls, 0); + }); + it('rejects an oversized nested result before durable publication', async () => { const outcomes: ToolOutcomeCommit[] = []; const harness = makeHarness({ @@ -538,7 +1441,12 @@ describe('ToolRuntime durable boundary', () => { }); // `null` means the turn carries no run id at all; `undefined` keeps the default. -function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | null = 'run-1') { +function makeHarness( + sink: RuntimeCommitSink, + order?: string[], + runId: string | null = 'run-1', + overrides: Partial = {}, +) { const messages: StoredMessage[] = []; const events: SessionEvent[] = []; const runtime = createTestToolRuntime({ @@ -554,6 +1462,7 @@ function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | getPermissionPauseTarget: () => null, ...(runId ? { runId } : {}), runtimeCommitSink: sink, + ...overrides, }); return { messages, @@ -601,6 +1510,80 @@ function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | }; } +function managedAdmission( + execute: RuntimeManagedMutationAdmission['execute'], + order?: string[], +): RuntimeManagedMutationAdmission { + return { + durableDispatch: managedMutationDispatch(), + execute, + dispose: async () => { + order?.push('dispose'); + }, + }; +} + +function managedOutcomeEvent( + operationId: string, + result: unknown, + isError: boolean, + options: { + durationMs?: number; + origin?: 'provider' | 'code_mode'; + modelVisibility?: 'visible' | 'hidden'; + toolCallId?: string; + parentToolCallId?: string; + parentOperationId?: string; + } = {}, +) { + const toolCallId = options.toolCallId ?? 'provider-call-1'; + return { + id: `${operationId}_response`, + invocationId: 'run-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 100, + partial: false, + role: 'tool' as const, + author: 'tool' as const, + origin: options.origin ?? ('provider' as const), + modelVisibility: options.modelVisibility ?? ('visible' as const), + content: { + kind: 'function_response' as const, + id: toolCallId, + name: 'Write', + result, + ...(isError ? { isError: true } : {}), + }, + refs: { + operationId, + toolCallId, + ...(options.parentToolCallId ? { parentToolCallId: options.parentToolCallId } : {}), + ...(options.parentOperationId ? { parentOperationId: options.parentOperationId } : {}), + }, + actions: { stateDelta: { durationMs: options.durationMs ?? 0 } }, + }; +} + +function managedMutationDispatch() { + return { + protocol: 'managed_mutation_v1' as const, + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: 'version_55555555555555555555555555555555', + baseAcceptedEventId: 'baseline-event-1', + baseHeadRevision: 1, + baseCommitOid: '1'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPaths: ['notes.txt'], + executionProfileDigest: `sha256:${'a'.repeat(64)}` as const, + }; +} + function tool(impl: MakaTool['impl']): MakaTool { return { name: 'Read', diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index f78e13240c9..0b8fd994754 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -65,7 +65,12 @@ import { computerUseModelCallArgs } from '@maka/core/computer-use'; import type { SessionHeader } from '@maka/core/session'; import type { ToolInvocationRecord } from '@maka/core/usage-stats/types'; import { redactSecrets } from '@maka/core/redaction'; -import { TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent } from '@maka/core/runtime-event'; +import { + TOOL_BOUNDARY_PROTOCOL_V1, + type RuntimeEvent, + type RuntimeEventManagedWorkspaceMutationV1, +} from '@maka/core/runtime-event'; +import { isDeepStrictEqual } from 'node:util'; import { recordToolArtifactsSafely, type ToolArtifactRecorder } from './tool-artifacts.js'; import { computerActionFields, describeComputerUseArgsViolation } from './computer-use-codec.js'; @@ -154,6 +159,8 @@ export interface MakaTool

{ }; /** Crash-recovery contract used by the durable tool boundary. */ recoveryMode?: ToolRecoveryMode; + /** Durable execution profile selected by the Host before T1. */ + durableExecutionProfile?: 'managed_mutation_v1'; /** Step-level admission contract. Exclusive tools cannot share an assistant step. */ executionSemantics?: 'parallel' | 'exclusive_step'; /** Nested CodeMode admission. Ordinary tools are nestable by default. */ @@ -456,6 +463,55 @@ export interface ToolRuntimeInput { recordToolArtifacts?: ToolArtifactRecorder; /** Optional Phase 2 T1/T2 commit boundary for hosts that persist RuntimeEvents. */ runtimeCommitSink?: RuntimeCommitSink; + /** Host-owned managed mutation admission. It may never fall back after returning a profile. */ + admitManagedMutation?: (input: { + readonly operationId: string; + readonly toolName: string; + readonly persistedArgs: unknown; + readonly abortSignal: AbortSignal; + }) => Promise; +} + +interface RuntimeManagedMutationOperationValue { + readonly result: T; + readonly outcome: { + readonly content: ToolResultContent; + readonly isError: boolean; + readonly durationMs: number; + }; +} + +/** + * The only operation evidence exposed to the workspace owner. Runtime keeps + * the provider-facing value private so the owner cannot replace the live + * result while committing a different durable response. + */ +export interface RuntimeManagedMutationOperationProof { + readonly content: ToolResultContent; + readonly isError: boolean; + readonly durationMs: number; +} + +export type RuntimeManagedMutationSettlement = + | { + readonly kind: 'workspace_successor_committed'; + readonly durableOutcome: RuntimeEvent; + } + | { + readonly kind: 'safely_discarded'; + /** Exact value returned to the provider and canonicalized for durable replay. */ + readonly providerResult: unknown; + readonly durableOutcome: RuntimeEvent; + } + | { readonly kind: 'unsettled'; readonly error: unknown }; + +export interface RuntimeManagedMutationAdmission { + readonly durableDispatch: Readonly; + execute( + operation: () => Promise, + ): Promise; + /** Idempotent for an unused, failed-T1, or already executed admission. */ + dispose(): Promise; } interface DurableToolAttempt { @@ -466,6 +522,12 @@ interface DurableToolAttempt { isError: boolean, durationMs?: number, ): Promise<{ id: string; operationId: string; ts: number }>; + adoptCommittedOutcome( + event: RuntimeEvent, + result: ToolResultContent, + isError: boolean, + durationMs: number, + ): { id: string; operationId: string; ts: number }; } class RuntimeCommitBoundaryError extends Error { @@ -479,6 +541,16 @@ class RuntimeCommitBoundaryError extends Error { } } +class RuntimeManagedMutationUnsettledError extends Error { + constructor(cause: unknown) { + super( + `Managed workspace mutation remains unsettled: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + this.name = 'RuntimeManagedMutationUnsettledError'; + } +} + class ToolResultLimitError extends Error { constructor() { super('Tool result byte limit exceeded'); @@ -1269,8 +1341,38 @@ export class ToolRuntime { } } + let managedMutationAdmission: RuntimeManagedMutationAdmission | undefined; + if (tool.durableExecutionProfile === 'managed_mutation_v1') { + if ( + (tool.name !== 'Write' && tool.name !== 'Edit') || + tool.recoveryMode !== 'reconcile' || + !dispatchOperationId || + !this.input.runtimeCommitSink || + !this.input.admitManagedMutation + ) { + const reason = 'Managed workspace mutation admission is unavailable before T1'; + await refuseBeforeDispatch(reason); + this.recordLoopGateOutcome(callSignature, true); + return this.errorReturn(reason); + } + try { + managedMutationAdmission = await this.input.admitManagedMutation({ + operationId: dispatchOperationId, + toolName: tool.name, + persistedArgs: structuredClone(persistedArgs), + abortSignal: ctx.abortSignal, + }); + } catch (error) { + const reason = `Managed workspace mutation admission failed: ${formatSyntheticToolErrorText(error)}`; + await refuseBeforeDispatch(reason); + this.recordLoopGateOutcome(callSignature, true); + return this.errorReturn(reason); + } + } + const reservedSubagentSlot = this.reserveSubagentSlot(tool); if (!reservedSubagentSlot) { + await disposeManagedMutationAdmission(managedMutationAdmission); trace?.emit('tool', 'tool_failed', 'Tool execution rejected by runtime limit', { toolUseId, toolName: tool.name, @@ -1290,11 +1392,15 @@ export class ToolRuntime { persistedArgs, modelFacingArgs, abortSignal: ctx.abortSignal, + ...(managedMutationAdmission + ? { managedMutation: managedMutationAdmission.durableDispatch } + : {}), ...(invocationId ? { invocationId } : {}), ...(runId ? { runId } : {}), }); } catch (error) { if (reservedSubagentSlot) this.releaseSubagentSlot(tool); + await disposeManagedMutationAdmission(managedMutationAdmission); throw error; } if (durableAttempt) { @@ -1329,96 +1435,235 @@ export class ToolRuntime { try { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); - const result = await tool.impl(structuredClone(executionArgs) as never, { - sessionId: this.input.sessionId, - turnId, - ...(runId ? { runId } : {}), - ...(this.input.orchestrationMode - ? { orchestrationMode: this.input.orchestrationMode } - : {}), - cwd: this.input.header.cwd, - executionBoundary, - permissionMode: this.input.header.permissionMode, - toolCallId: toolUseId, - // The id the call event actually carries, not the candidate: by here - // `prepareDurableToolAttempt` has pushed it on the dispatch lane. - ...(pushedCallEvent?.operationId ? { operationId: pushedCallEvent.operationId } : {}), - abortSignal: ctx.abortSignal, - emitOutput: output.emit, - emitProgress: (current, total) => { - const chunk = encodeToolStepProgress({ current, total }); - if (!chunk) return; - queue.push({ - type: 'tool_progress', - id: this.input.newId(), - turnId, - ts: this.input.now(), - toolUseId, - chunk, - ...activityIdentity, - }); - }, - ...(trace - ? { - emitRunTrace: ( - type: - | 'tool_started' - | 'tool_completed' - | 'tool_failed' - | 'skill_searched' - | 'skill_loaded' - | 'skill_load_failed', - message: string, - data?: Record, - ) => - trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, { - toolUseId, - toolName: tool.name, - ...(data ?? {}), - }), - } - : {}), - ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}), - ...(this.input.readChildAgentOutput - ? { readChildAgentOutput: this.input.readChildAgentOutput } - : {}), - ...this.buildChildAgentContext({ + const invokeTool = () => + tool.impl(structuredClone(executionArgs) as never, { + sessionId: this.input.sessionId, turnId, + ...(runId ? { runId } : {}), + ...(this.input.orchestrationMode + ? { orchestrationMode: this.input.orchestrationMode } + : {}), + cwd: this.input.header.cwd, + executionBoundary, + permissionMode: this.input.header.permissionMode, + toolCallId: toolUseId, + // The id the call event actually carries, not the candidate: by here + // `prepareDurableToolAttempt` has pushed it on the dispatch lane. + ...(pushedCallEvent?.operationId ? { operationId: pushedCallEvent.operationId } : {}), abortSignal: ctx.abortSignal, - trace, - toolUseId, - toolName: tool.name, - queue, - activityIdentity, - }), - askUserQuestion: (questions) => - this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), - requestSandboxBoundary: (expansion, justification) => - this.requestSandboxBoundary( + emitOutput: output.emit, + emitProgress: (current, total) => { + const chunk = encodeToolStepProgress({ current, total }); + if (!chunk) return; + queue.push({ + type: 'tool_progress', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + chunk, + ...activityIdentity, + }); + }, + ...(trace + ? { + emitRunTrace: ( + type: + | 'tool_started' + | 'tool_completed' + | 'tool_failed' + | 'skill_searched' + | 'skill_loaded' + | 'skill_load_failed', + message: string, + data?: Record, + ) => + trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, { + toolUseId, + toolName: tool.name, + ...(data ?? {}), + }), + } + : {}), + ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}), + ...(this.input.readChildAgentOutput + ? { readChildAgentOutput: this.input.readChildAgentOutput } + : {}), + ...this.buildChildAgentContext({ turnId, + abortSignal: ctx.abortSignal, + trace, toolUseId, - expansion, - justification, - ctx.abortSignal, + toolName: tool.name, queue, - ), - }); - if ( - ctx.maxResultBytes !== undefined && - serializedByteLength(result, ctx.maxResultBytes) > ctx.maxResultBytes - ) { - throw new ToolResultLimitError(); + activityIdentity, + }), + askUserQuestion: (questions) => + this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), + requestSandboxBoundary: (expansion, justification) => + this.requestSandboxBoundary( + turnId, + toolUseId, + expansion, + justification, + ctx.abortSignal, + queue, + ), + }); + const prepareOperationValue = async ( + immutableSnapshot = false, + ): Promise> => { + const rawResult = await invokeTool(); + const result = immutableSnapshot + ? snapshotManagedToolResult(rawResult, ctx.maxResultBytes) + : rawResult; + if ( + !immutableSnapshot && + ctx.maxResultBytes !== undefined && + serializedByteLength(result, ctx.maxResultBytes) > ctx.maxResultBytes + ) { + throw new ToolResultLimitError(); + } + const content = immutableSnapshot + ? Object.freeze(coerceResultContent(result)) + : coerceResultContent(result); + const outcome = { + content, + isError: deriveToolResultStatus(content, result) !== 'success', + durationMs: this.input.now() - startedAt, + }; + const value = { + result, + outcome: immutableSnapshot ? Object.freeze(outcome) : outcome, + }; + return immutableSnapshot ? Object.freeze(value) : value; + }; + let settledExecution: + | { + kind: 'managed'; + value: RuntimeManagedMutationOperationValue; + durableOutcome: RuntimeEvent; + } + | { kind: 'generic'; value: RuntimeManagedMutationOperationValue }; + if (managedMutationAdmission) { + const operationLifecycle: { + state: 'open' | 'running' | 'settled' | 'closed'; + } = { state: 'open' }; + let operationPromise: Promise | undefined; + let runtimeOwnedValue: RuntimeManagedMutationOperationValue | undefined; + const executeManagedOperation = (): Promise => { + if (operationLifecycle.state !== 'open') { + if (operationLifecycle.state === 'closed') { + return Promise.reject(new Error('Managed mutation operation capability is closed')); + } + return Promise.reject( + new Error('Managed mutation owner invoked the operation more than once'), + ); + } + operationLifecycle.state = 'running'; + operationPromise = (async () => { + try { + const value = await prepareOperationValue(true); + runtimeOwnedValue = value; + return { + // The canonical content is already recursively immutable, so + // the owner can read it without receiving a mutable alias. + content: value.outcome.content, + isError: value.outcome.isError, + durationMs: value.outcome.durationMs, + }; + } finally { + if (operationLifecycle.state === 'running') { + operationLifecycle.state = 'settled'; + } + } + })(); + // Runtime also observes the promise so a careless owner cannot + // turn an un-awaited operation rejection into an unhandled one. + void operationPromise.catch(() => undefined); + return operationPromise; + }; + let settlement: unknown; + let ownerFailed = false; + let ownerError: unknown; + try { + settlement = await managedMutationAdmission.execute(executeManagedOperation); + } catch (error) { + ownerFailed = true; + ownerError = error; + } + const ownerSettledWhileOperationRunning = operationLifecycle.state === 'running'; + if (ownerSettledWhileOperationRunning) { + try { + await operationPromise; + } catch { + // The terminal state is invalid regardless of how the detached + // operation settles. Join it so no side effect can occur later. + } finally { + operationLifecycle.state = 'closed'; + } + throw new RuntimeManagedMutationUnsettledError( + new Error('Managed mutation owner settled before the operation completed', { + ...(ownerFailed ? { cause: ownerError } : {}), + }), + ); + } + operationLifecycle.state = 'closed'; + if (ownerFailed) { + // Once managed T1 is durable, an admission/owner failure may never + // fall through to the generic synthetic T2 path. Only the owner can + // prove that a successor was accepted or the candidate was safely + // discarded; every other failure remains unsettled for recovery. + throw new RuntimeManagedMutationUnsettledError(ownerError); + } + const normalized = normalizeManagedMutationSettlement(settlement, ctx.maxResultBytes); + if (normalized.kind === 'workspace_successor_committed') { + if (!runtimeOwnedValue) { + throw new RuntimeManagedMutationUnsettledError( + new Error( + 'Managed mutation owner committed success without executing the operation', + ), + ); + } + settledExecution = { + kind: 'managed', + value: runtimeOwnedValue, + durableOutcome: normalized.durableOutcome, + }; + } else { + settledExecution = { + kind: 'managed', + value: normalized.value, + durableOutcome: normalized.durableOutcome, + }; + } + } else { + settledExecution = { kind: 'generic', value: await prepareOperationValue() }; } + const { result, outcome } = settledExecution.value; output.flush(); - const durationMs = this.input.now() - startedAt; - - const content = coerceResultContent(result); - const toolResultStatus = deriveToolResultStatus(content, result); - const durableOutcome = await durableAttempt?.commitOutcome( - content, - toolResultStatus !== 'success', - durationMs, - ); + const { content, durationMs } = outcome; + const toolResultStatus = outcome.isError ? 'error' : 'success'; + let durableOutcome: { id: string; operationId: string; ts: number } | undefined; + if (settledExecution.kind === 'managed') { + if (!durableAttempt) { + throw new RuntimeManagedMutationUnsettledError( + new Error('Managed mutation settlement has no durable T1 attempt'), + ); + } + durableOutcome = durableAttempt.adoptCommittedOutcome( + settledExecution.durableOutcome, + content, + outcome.isError, + durationMs, + ); + } else { + durableOutcome = await durableAttempt?.commitOutcome( + content, + outcome.isError, + durationMs, + ); + } if (hasSandboxDenial(content)) { const denialKey = sandboxDenialKey(tool.name, this.input.header.cwd, executionArgs); this.recentSandboxDenials.add(denialKey); @@ -1526,7 +1771,19 @@ export class ToolRuntime { pauseTarget?.resume(); } } catch (err) { - if (err instanceof RuntimeCommitBoundaryError) throw err; + if ( + err instanceof RuntimeCommitBoundaryError || + err instanceof RuntimeManagedMutationUnsettledError + ) { + throw err; + } + // Admission exists only after the owner has selected managed mode and + // Runtime has committed its T1 dispatch. From that point onward every + // normalization, size check, adoption, publication, and telemetry error + // is recovery-owned. It may never fall through to generic synthetic T2. + if (managedMutationAdmission) { + throw new RuntimeManagedMutationUnsettledError(err); + } if (isInteractionControlError(err)) throw err; output.flush(); const sandboxError = serializeSandboxError(err); @@ -1661,6 +1918,7 @@ export class ToolRuntime { } finally { this.recordLoopGateOutcome(callSignature, attemptFailed); if (reservedSubagentSlot) this.releaseSubagentSlot(tool); + await disposeManagedMutationAdmission(managedMutationAdmission); } } @@ -1671,6 +1929,7 @@ export class ToolRuntime { /** The projection the model replays as its own call. */ modelFacingArgs: unknown; abortSignal: AbortSignal; + managedMutation?: Readonly; invocationId?: string; runId?: string; }): Promise { @@ -1755,6 +2014,7 @@ export class ToolRuntime { toolName: input.tool.name, canonicalArgsHash, recoveryMode, + ...(input.managedMutation ? { managedMutation: input.managedMutation } : {}), }, }, refs: { @@ -1787,43 +2047,49 @@ export class ToolRuntime { } catch (error) { throw new RuntimeCommitBoundaryError('T1', error); } + const buildResponseEvent = ( + result: unknown, + isError: boolean, + durationMs: number | undefined, + ts: number, + ): RuntimeEvent => ({ + id: `${operationId}_response`, + invocationId, + runId, + sessionId: this.input.sessionId, + turnId: input.startEvent.turnId, + ts, + partial: false, + role: 'tool', + author: 'tool', + origin: input.startEvent.origin ?? 'provider', + modelVisibility: input.startEvent.modelVisibility ?? 'visible', + content: { + kind: 'function_response', + id: input.startEvent.toolUseId, + name: input.tool.name, + result, + ...(isError ? { isError: true } : {}), + }, + refs: { + operationId, + toolCallId: input.startEvent.toolUseId, + ...(input.startEvent.parentToolCallId + ? { parentToolCallId: input.startEvent.parentToolCallId } + : {}), + ...(input.startEvent.parentOperationId + ? { parentOperationId: input.startEvent.parentOperationId } + : {}), + }, + ...(durationMs !== undefined ? { actions: { stateDelta: { durationMs } } } : {}), + }); let committedOutcome: { id: string; operationId: string; ts: number } | undefined; return { operationId, responseEventId: `${operationId}_response`, commitOutcome: async (result, isError, durationMs) => { if (committedOutcome) return committedOutcome; - const responseEvent: RuntimeEvent = { - id: `${operationId}_response`, - invocationId, - runId, - sessionId: this.input.sessionId, - turnId: input.startEvent.turnId, - ts: this.input.now(), - partial: false, - role: 'tool', - author: 'tool', - origin: input.startEvent.origin ?? 'provider', - modelVisibility: input.startEvent.modelVisibility ?? 'visible', - content: { - kind: 'function_response', - id: input.startEvent.toolUseId, - name: input.tool.name, - result, - ...(isError ? { isError: true } : {}), - }, - refs: { - operationId, - toolCallId: input.startEvent.toolUseId, - ...(input.startEvent.parentToolCallId - ? { parentToolCallId: input.startEvent.parentToolCallId } - : {}), - ...(input.startEvent.parentOperationId - ? { parentOperationId: input.startEvent.parentOperationId } - : {}), - }, - ...(durationMs !== undefined ? { actions: { stateDelta: { durationMs } } } : {}), - }; + const responseEvent = buildResponseEvent(result, isError, durationMs, this.input.now()); try { await sink.commitToolOutcome({ operationId, @@ -1844,6 +2110,21 @@ export class ToolRuntime { ); return committedOutcome; }, + adoptCommittedOutcome: (event, result, isError, durationMs) => { + if (committedOutcome) return committedOutcome; + const expected = buildResponseEvent(result, isError, durationMs, event.ts); + if (!Number.isFinite(event.ts) || !isDeepStrictEqual(event, expected)) { + throw new RuntimeCommitBoundaryError( + 'T2', + new Error('Managed mutation settlement returned a mismatched durable outcome'), + ); + } + committedOutcome = { id: event.id, operationId, ts: event.ts }; + this.durableToolAttempts.delete( + durableAttemptKey(input.startEvent.turnId, input.startEvent.toolUseId), + ); + return committedOutcome; + }, }; } @@ -2847,6 +3128,76 @@ function uncertainOutcomeSignalFromError(error: unknown): ToolUncertainOutcomeSi }; } +function normalizeManagedMutationSettlement( + settlement: unknown, + maxResultBytes: number | undefined, +): + | { + kind: 'workspace_successor_committed'; + durableOutcome: RuntimeEvent; + } + | { + kind: 'safely_discarded'; + value: RuntimeManagedMutationOperationValue; + durableOutcome: RuntimeEvent; + } { + if (!settlement || typeof settlement !== 'object' || Array.isArray(settlement)) { + throw new Error('Managed mutation owner returned an invalid settlement'); + } + const record = settlement as unknown as Record; + const kind = record.kind; + if (kind === 'unsettled') { + throw new RuntimeManagedMutationUnsettledError(record.error); + } + if (kind !== 'workspace_successor_committed' && kind !== 'safely_discarded') { + throw new Error('Managed mutation owner returned an unknown settlement kind'); + } + const durableOutcomeValue = Object.hasOwn(record, 'durableOutcome') + ? record.durableOutcome + : undefined; + if ( + !durableOutcomeValue || + typeof durableOutcomeValue !== 'object' || + Array.isArray(durableOutcomeValue) + ) { + throw new Error('Managed mutation settlement has no durable outcome'); + } + const durableOutcome = durableOutcomeValue as RuntimeEvent; + + if (kind === 'workspace_successor_committed') { + return { + kind, + durableOutcome, + }; + } + + if (!Object.hasOwn(record, 'providerResult')) { + throw new Error('Managed safely-discarded settlement has no provider result'); + } + const providerResult = snapshotManagedToolResult(record.providerResult, maxResultBytes); + const response = durableOutcome.content; + if (response?.kind !== 'function_response' || response.isError !== true) { + throw new Error('Managed safely-discarded settlement has no durable error outcome'); + } + const content = Object.freeze(coerceResultContent(providerResult)); + const outcome = Object.freeze({ + content, + isError: true, + durationMs: + typeof durableOutcome.actions?.stateDelta?.durationMs === 'number' + ? durableOutcome.actions.stateDelta.durationMs + : 0, + }); + return { + kind, + value: Object.freeze({ + result: providerResult, + outcome, + }), + durableOutcome, + }; +} + function coerceResultContent(raw: unknown): ToolResultContent { if (typeof raw === 'string') return { kind: 'text', text: raw }; if (raw && typeof raw === 'object') { @@ -2864,6 +3215,168 @@ function coerceResultContent(raw: unknown): ToolResultContent { return { kind: 'text', text: String(raw ?? '') }; } +/** + * Builds the one durable JSON representation while enforcing its byte budget. + * The walk stops at the first over-budget token and never retains a mutable + * tool/owner-owned object alias. + */ +function snapshotManagedToolResult(value: unknown, maxBytes: number | undefined): unknown { + const budget: ManagedResultSnapshotBudget = { + bytes: 0, + limit: + maxBytes === undefined || maxBytes === Number.POSITIVE_INFINITY + ? Number.POSITIVE_INFINITY + : Number.isFinite(maxBytes) && maxBytes >= 0 + ? Math.floor(maxBytes) + : 0, + active: new WeakSet(), + }; + return snapshotStrictJsonValue(value, budget, '$'); +} + +interface ManagedResultSnapshotBudget { + bytes: number; + limit: number; + active: WeakSet; +} + +function snapshotStrictJsonValue( + value: unknown, + budget: ManagedResultSnapshotBudget, + path: string, +): unknown { + if (value === null) { + consumeManagedResultBytes(budget, 4); + return null; + } + if (typeof value === 'string') { + consumeManagedJsonStringBytes(budget, value); + return value; + } + if (typeof value === 'boolean') { + consumeManagedResultBytes(budget, value ? 4 : 5); + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Managed tool result must be strict JSON: ${path} is not finite`); + } + const canonical = Object.is(value, -0) ? 0 : value; + consumeManagedResultBytes(budget, JSON.stringify(canonical).length); + return canonical; + } + if (value === undefined) { + throw new Error(`Managed tool result must be strict JSON: ${path} is undefined`); + } + if (typeof value !== 'object') { + throw new Error(`Managed tool result must be strict JSON: ${path} has an unsupported type`); + } + if (budget.active.has(value)) { + throw new Error(`Managed tool result must be strict JSON: ${path} is cyclic`); + } + if (!Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`Managed tool result must be strict JSON: ${path} is not a plain object`); + } + } + if ('toJSON' in value) { + throw new Error(`Managed tool result must be strict JSON: ${path} defines toJSON`); + } + + budget.active.add(value); + try { + if (Array.isArray(value)) { + consumeManagedResultBytes(budget, 1); + const output: unknown[] = []; + for (let index = 0; index < value.length; index += 1) { + if (index > 0) consumeManagedResultBytes(budget, 1); + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor) { + throw new Error(`Managed tool result must be strict JSON: ${path} is a sparse array`); + } + if (!('value' in descriptor)) { + throw new Error( + `Managed tool result must be strict JSON: ${path}[${index}] is an accessor`, + ); + } + output.push(snapshotStrictJsonValue(descriptor.value, budget, `${path}[${index}]`)); + } + consumeManagedResultBytes(budget, 1); + return Object.freeze(output); + } + + consumeManagedResultBytes(budget, 1); + const output: Record = {}; + let emitted = 0; + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + if (emitted > 0) consumeManagedResultBytes(budget, 1); + consumeManagedJsonStringBytes(budget, key); + consumeManagedResultBytes(budget, 1); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) { + throw new Error(`Managed tool result must be strict JSON: ${path}.${key} is an accessor`); + } + Object.defineProperty(output, key, { + configurable: true, + enumerable: true, + value: snapshotStrictJsonValue(descriptor.value, budget, `${path}.${key}`), + writable: true, + }); + emitted += 1; + } + consumeManagedResultBytes(budget, 1); + return Object.freeze(output); + } finally { + budget.active.delete(value); + } +} + +function consumeManagedJsonStringBytes(budget: ManagedResultSnapshotBudget, value: string): void { + consumeManagedResultBytes(budget, 1); + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + let bytes: number; + if ( + code === 0x22 || + code === 0x5c || + code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ) { + bytes = 2; + } else if (code <= 0x1f) { + bytes = 6; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes = 4; + index += 1; + } else { + bytes = 6; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes = 6; + } else if (code <= 0x7f) { + bytes = 1; + } else if (code <= 0x7ff) { + bytes = 2; + } else { + bytes = 3; + } + consumeManagedResultBytes(budget, bytes); + } + consumeManagedResultBytes(budget, 1); +} + +function consumeManagedResultBytes(budget: ManagedResultSnapshotBudget, bytes: number): void { + if (budget.bytes > budget.limit - bytes) throw new ToolResultLimitError(); + budget.bytes += bytes; +} + function coerceTerminalFailure( tool: MakaTool, cwd: string, @@ -3074,6 +3587,17 @@ function durableAttemptKey(turnId: string, toolUseId: string): string { return JSON.stringify([turnId, toolUseId]); } +async function disposeManagedMutationAdmission( + admission: RuntimeManagedMutationAdmission | undefined, +): Promise { + try { + await admission?.dispose(); + } catch { + // Cleanup is best-effort. It must never rewrite a durable terminal outcome, + // nor obscure the primary pre-T1 or execution failure. + } +} + function providerToolErrorMessage(output: unknown): string | undefined { if (!output || typeof output !== 'object' || Array.isArray(output)) return undefined; const record = output as Record;