diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 07bafbf32d..26f4b5518e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,6 +47,19 @@ Runtime Host owns Session and Turn identity, agent lifecycle, continuation, tool 3. Agent Graph schedules dependent work using child Sessions and sends every activation back through the same Runtime. 4. Storage owns interactive Runtime state. It has no Eval-specific root, TaskRun ledger, or experiment result authority. +## Tool resource admission + +Tool execution uses three separate layers. `ToolScheduler` orders conflicting +claims inside one provider batch. A process-owned shared/exclusive coordinator +provides cross-batch correctness: `all()` holds its exclusive side, while each +participating non-empty resource authority holds the shared side. Domain owners +then acquire their own leases (for example filesystem exact/tree read-write +leases) and revalidate identity before the effect. + +The fixed acquisition order is process admission, then domain lease. Explicit +`none()` operations bypass both the batch conflict graph and the process +barrier; `all()` does not mean "every asynchronous operation in the process." + ## Eval boundary ```text diff --git a/ARCHITECTURE.zh-CN.md b/ARCHITECTURE.zh-CN.md index 5557d425b1..2b551178e5 100644 --- a/ARCHITECTURE.zh-CN.md +++ b/ARCHITECTURE.zh-CN.md @@ -47,6 +47,17 @@ Runtime Host 拥有 Session 和 Turn identity、agent lifecycle、continuation 3. Agent Graph 通过 child Session 调度依赖工作,并把每次 activation 送回同一 Runtime。 4. Storage 只拥有交互 Runtime 状态,不再有 Eval 专用 root、TaskRun ledger 或实验结果 authority。 +## 工具资源准入 + +工具执行分为三个彼此独立的层次。`ToolScheduler` 只在单个 provider batch +内按冲突 claim 排序;进程级 shared/exclusive coordinator 提供跨 batch +correctness:`all()` 获取 exclusive,所有参与建模的非空资源 authority 获取 +shared;随后领域 owner 再获取自己的 lease(例如 filesystem exact/tree +读写 lease),并在真实 effect 前重新校验 identity。 + +固定获取顺序是 process admission,再 domain lease。显式 `none()` 同时绕过 +batch 冲突图和 process barrier;`all()` 并不表示“阻塞进程中的一切异步操作”。 + ## Eval 边界 ```text diff --git a/docs/filesystem-read-tree-lease-test-report.md b/docs/filesystem-read-tree-lease-test-report.md new file mode 100644 index 0000000000..d302028f7c --- /dev/null +++ b/docs/filesystem-read-tree-lease-test-report.md @@ -0,0 +1,162 @@ + + +# Filesystem Read/Tree Lease 测试报告 + +日期:2026-09-04 + +平台:Windows,Node.js v22.23.2 + +工作区:`D:\harness learning\maka-agent` + +## 1. 结论 + +Filesystem Read/Tree Lease 的实现和定向回归通过。最终扩展矩阵为 **62 pass、0 fail、0 skip**。 + +本轮补齐了以下关键证据: + +- 独立 `settleToolCallBatch()` 之间的 Read/Edit 与 Grep/Write 冲突; +- 不依赖 Scheduler 的 prepared Read/direct Edit owner 互斥; +- structured single-operation patch 与 Read/Grep 的直接 overlap; +- root/child tool composition 共享同一个 filesystem coordinator; +- multi-key 原子准入、writer fairness、abort/reject release; +- Windows junction 与 POSIX symlink 的 canonical alias、越界和“不跟随删除”语义; +- unknown tool fallback、`all`/`none` 真值表和 provider-order 结果槽稳定性。 + +原先的 State Root ownership namespace 权限阻塞在 unrestricted 环境下不再出现。Runtime Host 全量测试仍不能宣称全绿,但剩余失败已确认是独立的 Windows SQLite teardown、默认测试并发资源压力和 real-model terminal timeout,不是本次 filesystem lease 行为失败。 + +## 2. 实现范围 + +实现包含: + +- process-owned `FilesystemLeaseCoordinator`; +- exact/tree read-write 冲突判断; +- writer-fair、abort-aware 等待队列; +- `acquireMany` 风格的 multi-key 原子准入,禁止部分持有; +- canonical、Windows case-folded lease key; +- Read/Write/Edit/Grep/Glob/apply_patch 的 owner-level lease; +- freeform multi-file patch 的整组 lease interval; +- Runtime Host root/child composition 共享 coordinator; +- patch unknown-outcome 与现有 authority contract 对齐。 + +## 3. 测试矩阵 + +| 类别 | 主要场景 | 结果 | +| --- | --- | --- | +| Lease key | POSIX canonical、Windows case fold、`src`/`src2` separator boundary | 通过 | +| Coordinator | exact/tree RW、并发 read、独立路径 fan-out、writer fairness | 通过 | +| Abort/release | queued abort、pre-abort、active abort、effect reject 后释放 | 通过 | +| Multi-key admission | reversed keys、dedupe、全量原子准入、禁止 partial admission | 通过 | +| 独立 batch | Read/Edit、Grep tree/child Write | 通过 | +| Owner correctness | prepared/direct 路径绕过 Scheduler 后仍互斥 | 通过 | +| Patch | structured patch overlap、multi-file interval、exact-write claims | 通过 | +| Root/child | 两个 composition 共享 coordinator | 通过 | +| Alias | prepared junction/symlink canonical lease identity | 通过 | +| Boundary | junction/symlink 越界拒绝、bypass 可访问 | 通过 | +| Delete link | 删除 reparse/link entry,不跟随删除目标 | 通过 | +| Kimi semantics | unknown→`all`、`all`/`none`、fairness、provider order | 通过 | + +## 4. 执行结果 + +### 4.1 类型检查与构建 + +以下命令通过: + +```text +npm --workspace @maka/runtime run typecheck +npm --workspace @maka/runtime-host run typecheck +npm --workspace @maka/runtime run build +npm --workspace @maka/runtime-host run build +``` + +### 4.2 最终扩展矩阵 + +运行: + +```text +node --test \ + packages/runtime/dist/__tests__/filesystem-apply-patch.test.js \ + packages/runtime/dist/__tests__/filesystem-authority-contract.test.js \ + packages/runtime/dist/__tests__/filesystem-authority-leases.test.js \ + packages/runtime/dist/__tests__/filesystem-authority.test.js \ + packages/runtime/dist/__tests__/filesystem-lease-coordinator.test.js \ + packages/runtime/dist/__tests__/filesystem-lease-key.test.js \ + packages/runtime/dist/__tests__/filesystem-tool-call-batch-scenarios.test.js \ + packages/runtime/dist/__tests__/tool-authority-kimi-semantics-batch.test.js \ + packages/runtime-host/dist/__tests__/filesystem-lease-composition.test.js +``` + +结果: + +```text +tests 62 +pass 62 +fail 0 +skipped 0 +``` + +### 4.3 Windows link 定向矩阵 + +Windows 当前进程令牌没有 `SeCreateSymbolicLinkPrivilege`。测试采用平台等价策略:Windows 使用无需提权的 directory junction,POSIX 保留 symlink。该策略实际验证 reparse entry 的 canonicalization、越界拒绝和不跟随删除,而不是简单跳过。 + +结果: + +```text +tests 23 +pass 23 +fail 0 +skipped 0 +``` + +### 4.4 静态质量检查 + +以下检查通过: + +```text +biome format +biome lint +git diff --check +``` + +## 5. Runtime Host 全量测试记录 + +使用 unrestricted filesystem 权限后,Host 测试不再出现 `StorageRootAuthorityError` 或 State Root ownership namespace 解析失败。 + +全量测试进行了两种运行: + +1. 默认 Node 文件并发:多个 Host/child 进程出现 JavaScript heap OOM,随后残留句柄导致测试不退出。 +2. `--test-concurrency=1` 串行:消除了 OOM,但在 Windows SQLite 临时库清理阶段稳定出现 `EBUSY`,并在后续大型 composition 文件中出现长时间不退出。 + +独立复现结果: + +- `canonical-session-projection.test.js`:8 个用例均在清理 `runtime.sqlite`、`runtime.sqlite-wal` 或 `runtime.sqlite-shm` 时因 `EBUSY` 失败; +- `execution-model-composition.test.js`:存在既有 real-model Turn terminal timeout、SQLite `EBUSY`,随后测试进程不退出; +- 本次新增的 `filesystem-lease-composition.test.js` 通过; +- Host production composition、State Root startup、root/child filesystem coordinator 路径均可运行。 + +因此,本报告不将 Runtime Host 全量 suite 标记为全绿。剩余问题应作为独立的 Windows SQLite close/cleanup、测试并发上限及 real-model timeout 工作处理。 + +## 6. Review disposition + +测试已经证明 filesystem correctness 不依赖单一 batch Scheduler。仍需在 PR 文案中明确: + +- unknown real tool 默认 `all` 是安全优先的吞吐回退; +- provider order 只用于冲突调度和结果槽稳定性,不表达数据依赖; +- subagent fan-out 继续由既有 capacity limiter 控制,capacity 尚未统一表达为本 authority contract; +- global `all`、dynamic MCP policy 和 Bash workspace-scoped coarse authority 不属于本次 filesystem lease 实现范围。 diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 9b18f5b261..685d447307 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -58,6 +58,7 @@ import { type BackendFactoryContext } from '@maka/runtime/session-manager'; import { type AiSdkBackendInput, type RunTraceEvent } from '@maka/runtime/ai-sdk-backend'; import { type FilesystemWorkerExecuteInput } from '@maka/runtime/filesystem-worker'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; +import { ToolAuthorityRegistry, ToolPreparationService } from '@maka/runtime/tool-preparation'; import { type ProxiedFetchProxy, type ProxiedFetchTransport, @@ -147,6 +148,33 @@ const HEADLESS_CODING_V1_PROMPT_HASH = const HEADLESS_CODING_V1_TOOLS_HASH = 'sha256:aa3ab56a7b67dde133fffe885f4def81735c93015202e31ecb339a84863f6d03'; const execFileAsync = promisify(execFile); + +test('injects one caller-owned preparation service into every backend', async () => { + const preparationService = new ToolPreparationService(new ToolAuthorityRegistry()); + const createBackend = () => + createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => readyExecutionConnection(), + readPricing: async () => ({ revision: 0, overrides: [] }), + preparationService, + }), + ); + const [first, second] = await Promise.all([createBackend(), createBackend()]); + try { + assert.equal( + (first as unknown as { preparationService: ToolPreparationService }).preparationService, + preparationService, + ); + assert.equal( + (second as unknown as { preparationService: ToolPreparationService }).preparationService, + preparationService, + ); + } finally { + await Promise.all([first.dispose(), second.dispose()]); + } +}); + test('backend creation resolves a bound Session by immutable Connection identity', async () => { let observedRef: unknown; await createHostAiSdkBackend( @@ -3962,6 +3990,7 @@ function backendCreationFixture(input: { createFetchTransport?: HostAiSdkBackendInput['createFetchTransport']; createRunComposer?: HostAiSdkBackendInput['createRunComposer']; artifacts?: HostAiSdkBackendInput['artifacts']; + preparationService?: HostAiSdkBackendInput['preparationService']; }): HostAiSdkBackendInput { const runtimePolicy = input.runtimePolicy ?? @@ -4035,6 +4064,8 @@ function backendCreationFixture(input: { runtimePolicy, ...(input.oauthCredentials ? { oauthCredentials: input.oauthCredentials } : {}), createRunComposer, + preparationService: + input.preparationService ?? new ToolPreparationService(new ToolAuthorityRegistry()), artifacts: input.artifacts ?? {}, executionArtifacts: { recordToolArtifacts: async () => undefined, diff --git a/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts b/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts new file mode 100644 index 0000000000..ec2f06c0b2 --- /dev/null +++ b/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { + buildBuiltinToolComposition, + buildBuiltinTools, + type BuildBuiltinToolsOptions, +} from '@maka/runtime/builtin-tools'; +import { createFilesystemLeaseCoordinator } from '@maka/runtime/filesystem-lease-coordinator'; +import { createProcessResourceAdmissionCoordinator } from '@maka/runtime/process-resource-admission'; +import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime'; +import { createHostChildAgentToolComposition } from '../server/child-agent-composition.js'; + +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +test('root and child tool compositions share one filesystem coordinator', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-root-child-leases-'))); + try { + await writeFile(join(cwd, 'shared.txt'), 'before', 'utf8'); + const coordinator = createFilesystemLeaseCoordinator(); + const readStarted = deferred(); + const releaseRead = deferred(); + const calls: string[] = []; + const filesystemWorker: NonNullable = { + async execute(input) { + calls.push(input.operation.kind); + if (input.operation.kind === 'read') { + readStarted.resolve(); + await releaseRead.promise; + return { kind: 'read', content: 'before' } as const; + } + if (input.operation.kind === 'edit') { + return { + kind: 'edit', + ok: true, + path: input.operation.path, + replacements: 1, + matchedVia: 'exact', + startLine: 1, + endLine: 1, + } as const; + } + throw new Error(`Unexpected operation ${input.operation.kind}`); + }, + }; + const rootRead = buildBuiltinTools({ + filesystemWorker, + filesystemLeaseCoordinator: coordinator, + }).find((tool) => tool.name === 'Read') as MakaTool<{ path: string }, unknown> | undefined; + const childEdit = createHostChildAgentToolComposition({ + builtinTools: { filesystemWorker, filesystemLeaseCoordinator: coordinator }, + worktreePatchWriteBackAvailable: true, + }).childTools.find((tool) => tool.name === 'Edit') as + | MakaTool<{ path: string; old_string: string; new_string: string }, unknown> + | undefined; + assert.ok(rootRead); + assert.ok(childEdit); + const executionBoundary = createManagedExecutionBoundary( + createWorkspaceWritePermissionProfile(), + 0, + ); + const context = (toolCallId: string): MakaToolContext => ({ + sessionId: toolCallId === 'root-read' ? 'root-session' : 'child-session', + turnId: 'turn', + toolCallId, + cwd, + permissionMode: 'ask', + executionBoundary, + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }); + + const read = rootRead.impl({ path: 'shared.txt' }, context('root-read')); + await readStarted.promise; + const edit = childEdit.impl( + { path: 'shared.txt', old_string: 'before', new_string: 'after' }, + context('child-edit'), + ); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(calls, ['read']); + + releaseRead.resolve(); + await Promise.all([read, edit]); + assert.deepEqual(calls, ['read', 'edit']); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('root all() and child filesystem tools share one process admission coordinator', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-root-child-process-'))); + try { + await writeFile(join(cwd, 'shared.txt'), 'before', 'utf8'); + const processAdmission = createProcessResourceAdmissionCoordinator(); + const releaseAll = deferred(); + const allStarted = deferred(); + let workerCalls = 0; + const filesystemWorker: NonNullable = { + async execute(input) { + workerCalls += 1; + assert.equal(input.operation.kind, 'read'); + return { kind: 'read', content: 'before' } as const; + }, + }; + const options: BuildBuiltinToolsOptions = { + filesystemWorker, + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + processResourceAdmissionCoordinator: processAdmission, + }; + const root = buildBuiltinToolComposition(options); + const rootAllAuthority = root.authorityRegistry.resolve('Bash'); + assert.ok(rootAllAuthority); + const allOperation = await rootAllAuthority.prepare( + {}, + { + sessionId: 'root-session', + turnId: 'root-turn', + toolCallId: 'root-all', + cwd, + effect: async () => { + allStarted.resolve(); + await releaseAll.promise; + }, + }, + ); + const all = allOperation.execute(); + await allStarted.promise; + + const childRead = createHostChildAgentToolComposition({ + builtinTools: options, + worktreePatchWriteBackAvailable: true, + }).childTools.find((tool) => tool.name === 'Read') as + | MakaTool<{ path: string }, unknown> + | undefined; + assert.ok(childRead); + const read = childRead.impl( + { path: 'shared.txt' }, + { + sessionId: 'child-session', + turnId: 'child-turn', + toolCallId: 'child-read', + cwd, + permissionMode: 'ask', + executionBoundary: createManagedExecutionBoundary( + createWorkspaceWritePermissionProfile(), + 0, + ), + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }, + ); + await Promise.resolve(); + assert.equal(workerCalls, 0); + + releaseAll.resolve(); + await all; + await read; + assert.equal(workerCalls, 1); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 7c0f8f90e0..f3a77449bf 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -77,6 +77,10 @@ import { validateShellPreference, } from '@maka/runtime/shell-detect'; import { type MakaTool } from '@maka/runtime/tool-runtime'; +import { buildBuiltinToolComposition } from '@maka/runtime/builtin-tools'; +import { processFilesystemLeases } from '@maka/runtime/filesystem-lease-coordinator'; +import { processResourceAdmissions } from '@maka/runtime/process-resource-admission'; +import { ToolPreparationService } from '@maka/runtime/tool-preparation'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; import { isHostedExecutionTerminal } from './hosted-execution-authority.js'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; @@ -447,9 +451,18 @@ export async function createExecutionRuntimeHostComposition( }, } : {}), + filesystemLeaseCoordinator: processFilesystemLeases, + processResourceAdmissionCoordinator: processResourceAdmissions, ...(sandboxManager ? { sandboxManager } : {}), ...(filesystemWorker ? { filesystemWorker } : {}), }; + // Process lifetime: every root/child backend resolves through this exact + // registry and synthesis service. Turn-local tool rebuilding only changes + // declarations such as the selected shell; it never creates authorities. + const toolPreparationService = new ToolPreparationService( + buildBuiltinToolComposition(builtinTools).authorityRegistry, + processResourceAdmissions, + ); const webSearchService = createHostWebSearchService({ policy: runtimePolicyStores.operations, }); @@ -733,6 +746,7 @@ export async function createExecutionRuntimeHostComposition( context: backendContext, runtimePolicy: runtimePolicyStores, oauthCredentials, + preparationService: toolPreparationService, createRunComposer: createInteractiveRunComposerFactory({ skills, memory: requireMemory(memory), diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index f06cb71872..459d948f3f 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -25,6 +25,7 @@ import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { PermissionMode } from '@maka/core/permission'; import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; +import type { ToolPreparationService } from '@maka/runtime/tool-preparation'; import { buildDefaultContextBudgetPolicy, resolveSelectedModelContextWindow, @@ -78,6 +79,7 @@ export interface HostAiSdkBackendInput { readonly runtimePolicy: HostExecutionRuntimePolicyAuthority; readonly oauthCredentials: HostOAuthExecutionAuthority; readonly createRunComposer: HostRunComposerFactory; + readonly preparationService: ToolPreparationService; readonly memoryExtraction?: HostMemoryExtractionCoordinator; readonly artifacts: HostExecutionArtifactAuthority; readonly contextOffload?: InteractiveContextOffloadReader; @@ -366,6 +368,7 @@ async function buildHostAiSdkBackend( modelId: target.model, modelFactory, tools: [...modelComposition.tools], + preparationService: input.preparationService, toolAvailability: modelComposition.toolAvailability, ...(modelComposition.planTraceContext ? { planTraceContext: modelComposition.planTraceContext } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6d44e393bc..74610e90a8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -10,6 +10,8 @@ "./ai-sdk-backend": "./dist/ai-sdk-backend.js", "./mcp-tools": "./dist/mcp-tools.js", "./builtin-tools": "./dist/builtin-tools.js", + "./filesystem-lease-coordinator": "./dist/filesystem-lease-coordinator.js", + "./process-resource-admission": "./dist/process-resource-admission.js", "./shell-tools": "./dist/shell-tools.js", "./shell-run-manager": "./dist/shell-run-manager.js", "./deep-research-tools": "./dist/deep-research-tools.js", @@ -113,6 +115,7 @@ "./tool-result-archive-capability": "./dist/tool-result-archive-capability.js", "./tool-result-archive-resource": "./dist/tool-result-archive-resource.js", "./tool-runtime": "./dist/tool-runtime.js", + "./tool-preparation": "./dist/tool-preparation.js", "./web-fetch-tool": "./dist/web-fetch-tool.js", "./web-search-tool": "./dist/web-search-tool.js", "./xai-oauth-enrollment": "./dist/xai-oauth-enrollment.js" diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 055875101e..73f399167a 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -95,7 +95,10 @@ import type { OpenAiResponsesSemanticBaseline } from '../openai-responses-contin import type { OpenAiResponsesTransportState } from '../openai-responses-websocket.js'; import { getAIModel } from '../model-factory.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { ToolAccesses } from '../tool-access.js'; import { testInvocationOpening } from './invocation-fixture.js'; +import { ToolAuthorityRegistry } from '../preparation/tool-authority-registry.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; describe('AiSdkBackend ApplyPatch routing', () => { test('advertises apply_patch only to supported native OpenAI models', async () => { @@ -5189,6 +5192,7 @@ describe('AiSdkBackend model history', () => { modelId: 'mock-model-id', modelFactory: () => completionModel(), tools: [], + preparationService: new ToolPreparationService(new ToolAuthorityRegistry()), newId: idGenerator(), now: monotonicClock(), readExecutionBoundary: readExternalExecutionBoundary, @@ -6923,6 +6927,31 @@ describe('AiSdkBackend error surfaces', () => { }, }, ], + preparationService: new ToolPreparationService( + new ToolAuthorityRegistry([ + [ + 'Read', + { + prepare: async (input, context) => ({ + claims: [ + { + kind: 'keyed' as const, + authority: 'filesystem:test', + key: (input as { path: string }).path, + mode: 'read' as const, + }, + ], + execute: (signal, fallbackEffect) => + fallbackEffect + ? fallbackEffect() + : context.effect + ? context.effect(signal) + : Promise.resolve(), + }), + }, + ], + ]), + ), runtimeCommitSink: { commitToolPrepared: async ({ providerToolCallId }) => { if (providerToolCallId === 'tool-1') throw new Error('T1 unavailable'); @@ -10992,6 +11021,142 @@ describe('AiSdkBackend RunTrace', () => { }); describe('AiSdkBackend tool execution', () => { + test('schedules one provider batch by declared resources without starving a queued writer', async () => { + const durable = durableTurnHarness('turn-resource-scheduler', 'schedule file work'); + const gates = new Map( + ['reader-1', 'writer', 'reader-2', 'independent'].map((label) => [label, makeGate()]), + ); + const started: string[] = []; + let providerCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + providerCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + providerCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + ...[ + { label: 'reader-1', path: 'a.ts', operation: 'read' }, + { label: 'writer', path: 'a.ts', operation: 'write' }, + { label: 'reader-2', path: 'a.ts', operation: 'read' }, + { label: 'independent', path: 'b.ts', operation: 'write' }, + ].map( + ({ label, path, operation }): LanguageModelV4StreamPart => ({ + type: 'tool-call', + toolCallId: label, + toolName: 'ScheduledFile', + input: JSON.stringify({ label, path, operation }), + }), + ), + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'done' }, + { type: 'text-delta', id: 'done', delta: 'done' }, + { type: 'text-end', id: 'done' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const scheduledTool: MakaTool<{ + label: string; + path: string; + operation: 'read' | 'write'; + }> = { + name: 'ScheduledFile', + description: 'resource scheduler test tool', + parameters: z.object({ + label: z.string(), + path: z.string(), + operation: z.enum(['read', 'write']), + }), + impl: async ({ label }) => { + throw new Error(`Prepared operation was bypassed for ${label}`); + }, + }; + const preparationService = new ToolPreparationService( + new ToolAuthorityRegistry([ + [ + 'ScheduledFile', + { + prepare: (input) => { + const { label, path, operation } = input as { + label: string; + path: string; + operation: 'read' | 'write'; + }; + return Promise.resolve({ + claims: + label === 'independent' + ? [] + : [ + { + kind: 'keyed' as const, + authority: 'filesystem:workspace', + key: path, + mode: operation === 'read' ? ('read' as const) : ('write' as const), + }, + ], + execute: async () => { + started.push(label); + await gates.get(label)!.promise; + return { label }; + }, + }); + }, + }, + ], + ]), + ); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [scheduledTool], + preparationService, + maxSteps: 3, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const running = drainDurably(backend.send(durable.input()), durable); + await waitFor(() => started.length === 2); + assert.deepEqual(started, ['reader-1', 'independent']); + gates.get('reader-1')!.release(); + await waitFor(() => started.includes('writer')); + assert.deepEqual(started, ['reader-1', 'independent', 'writer']); + gates.get('writer')!.release(); + await waitFor(() => started.includes('reader-2')); + assert.deepEqual(started, ['reader-1', 'independent', 'writer', 'reader-2']); + gates.get('reader-2')!.release(); + gates.get('independent')!.release(); + + const events = await running; + assert.equal(providerCalls, 2); + assert.equal(events.at(-1)?.type, 'complete'); + }); + test('WebSearch telemetry never copies the user-derived query', async () => { const telemetry: Array<{ argsSummary?: string }> = []; const backend = createTestAiSdkBackend({ diff --git a/packages/runtime/src/__tests__/apply-patch-batch.test.ts b/packages/runtime/src/__tests__/apply-patch-batch.test.ts new file mode 100644 index 0000000000..2b72eca9be --- /dev/null +++ b/packages/runtime/src/__tests__/apply-patch-batch.test.ts @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { ToolOutcomeUnknownError } from '@maka/core/events'; +import { + ApplyPatchBatchOutcomeUnknownError, + executeApplyPatchOperations, +} from '../apply-patch-batch.js'; +import type { ApplyPatchOperation } from '../filesystem-executor.js'; + +const operations: readonly ApplyPatchOperation[] = [ + { type: 'delete_file', path: 'a.txt' }, + { type: 'delete_file', path: 'b.txt' }, + { type: 'delete_file', path: 'c.txt' }, +]; + +describe('executeApplyPatchOperations', () => { + it('returns the definitely committed prefix for an ordinary failure', async () => { + const seen: string[] = []; + const result = await executeApplyPatchOperations(operations, async (operation) => { + seen.push(operation.path); + if (operation.path === 'b.txt') throw new Error('broken'); + }); + assert.equal(result.status, 'failed'); + assert.deepEqual(result.applied, [{ type: 'delete_file', path: 'a.txt' }]); + assert.deepEqual(seen, ['a.txt', 'b.txt']); + }); + + it('preserves outcome-unknown classification and uncertain-operation metadata', async () => { + const seen: string[] = []; + await assert.rejects( + executeApplyPatchOperations(operations, async (operation) => { + seen.push(operation.path); + if (operation.path === 'b.txt') throw new ToolOutcomeUnknownError('unknown'); + }), + (error: unknown) => { + assert.ok(error instanceof ToolOutcomeUnknownError); + assert.ok(error instanceof ApplyPatchBatchOutcomeUnknownError); + assert.deepEqual(error.applied, [{ type: 'delete_file', path: 'a.txt' }]); + assert.deepEqual(error.uncertain, { type: 'delete_file', path: 'b.txt' }); + return true; + }, + ); + assert.deepEqual(seen, ['a.txt', 'b.txt']); + }); +}); diff --git a/packages/runtime/src/__tests__/builtin-tool-access.test.ts b/packages/runtime/src/__tests__/builtin-tool-access.test.ts new file mode 100644 index 0000000000..301aa13476 --- /dev/null +++ b/packages/runtime/src/__tests__/builtin-tool-access.test.ts @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { buildBuiltinToolComposition } from '../builtin-tools.js'; +import { hostFilesystemLeaseKey } from '../filesystem-lease-key.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; + +describe('builtin tool resource claims', () => { + let cwd: string; + const tools = new Map(); + let preparationService: ToolPreparationService; + // claim.key must equal the process-wide coordinator key, including the + // platform normalization used by the filesystem authority. + const expectedKey = (path: string) => hostFilesystemLeaseKey(resolve(cwd, path)); + + before(async () => { + cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-claims-'))); + const composition = buildBuiltinToolComposition(); + preparationService = new ToolPreparationService(composition.authorityRegistry); + for (const tool of composition.tools) tools.set(tool.name, tool); + }); + + after(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + test('maps file reads and writes to canonical keyed claims', async () => { + assert.deepEqual(await claims(preparationService, tools, 'Read', { path: 'a.ts' }, cwd), [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey('a.ts'), + mode: 'read', + scope: 'exact', + }, + ]); + assert.deepEqual( + await claims(preparationService, tools, 'Write', { path: 'a.ts', content: 'x' }, cwd), + [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey('a.ts'), + mode: 'write', + scope: 'exact', + }, + ], + ); + for (const [name, input] of [ + ['Edit', { path: 'a.ts', old_string: 'a', new_string: 'b' }], + ['FormatJson', { path: 'a.json' }], + ] as const) { + assert.deepEqual(await claims(preparationService, tools, name, input, cwd), [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey(input.path), + mode: 'write', + scope: 'exact', + }, + ]); + } + }); + + test('keeps authority bindings exclusively in the registry', () => { + const authorityToolNames = [ + 'Read', + 'Write', + 'Edit', + 'FormatJson', + 'Glob', + 'Grep', + 'apply_patch', + ]; + const authorityTools = [...tools.values()].filter((tool) => + authorityToolNames.includes(tool.name), + ); + assert.deepEqual(authorityTools.map((tool) => tool.name).sort(), authorityToolNames.sort()); + assert.equal( + authorityTools.every((tool) => !('prepare' in tool) && !('resourceAuthority' in tool)), + true, + ); + }); + + test('runtime-resource reads declare no claims (do not enter the Scheduler)', async () => { + assert.deepEqual( + await claims(preparationService, tools, 'Read', { ref: 'runtime://resource' }, cwd), + [], + ); + }); + + test('maps Glob and Grep to recursive tree-read claims', async () => { + assert.deepEqual( + await claims(preparationService, tools, 'Glob', { pattern: '**/*.ts', cwd: 'src' }, cwd), + [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey('src'), + mode: 'read', + scope: 'tree', + }, + ], + ); + assert.deepEqual(await claims(preparationService, tools, 'Grep', { pattern: 'TODO' }, cwd), [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey('.'), + mode: 'read', + scope: 'tree', + }, + ]); + }); + + test('declares a single-operation apply_patch target as one write claim', async () => { + assert.deepEqual( + await claims( + preparationService, + tools, + 'apply_patch', + { + callId: 'patch-claim', + operation: { type: 'update_file', path: 'changed.txt', diff: '@@\n-a\n+b\n' }, + }, + cwd, + ), + [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey('changed.txt'), + mode: 'write', + scope: 'exact', + }, + ], + ); + // Freeform multi-operation patches share the production parser and claim + // every target up front in stable key order. + assert.deepEqual( + await claims( + preparationService, + tools, + 'apply_patch', + [ + '*** Begin Patch', + '*** Add File: b.txt', + '+b', + '*** Add File: a.txt', + '+a', + '*** End Patch', + ].join('\n'), + cwd, + ), + ['a.txt', 'b.txt'].map((path) => ({ + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey(path), + mode: 'write', + scope: 'exact', + })), + ); + }); + + test('composes the stable Kimi policy into the production registry', async () => { + const composition = buildBuiltinToolComposition(); + const service = new ToolPreparationService(composition.authorityRegistry); + const expectedNone = [ + 'WebSearch', + 'WebFetch', + 'agent_spawn', + 'agent_list', + 'agent_output', + 'view_agent_graph', + 'agent_swarm_status', + 'SkillSearch', + 'StopBackgroundTask', + 'WriteStdin', + 'todo_read', + 'todo_write', + 'SearchHistory', + 'ReadHistory', + 'ScheduledTask', + 'GoalSet', + 'GoalClear', + 'GoalStatus', + 'GoalPause', + 'GoalResume', + 'SubmitPlan', + 'update_plan', + 'cancel_plan', + ]; + const expectedAll = [ + 'Bash', + 'update_agent_graph', + 'yield_agent_graph', + 'AskUserQuestion', + 'request_sandbox_boundary', + 'Skill', + 'tool_search', + 'maka_tool_search', + 'maka_computer', + 'UnknownMcpTool', + ]; + for (const name of expectedNone) { + assert.deepEqual(await policyClaims(service, name, cwd), [], name); + } + for (const name of expectedAll) { + assert.deepEqual(await policyClaims(service, name, cwd), [{ kind: 'all' }], name); + } + }); +}); + +async function policyClaims( + service: ToolPreparationService, + name: string, + cwd: string, +): Promise { + const tool: MakaTool = { + name, + description: 'authority policy probe', + parameters: undefined, + impl: async () => undefined, + }; + return ( + await service.prepare({ + tool, + input: {}, + ctx: { + sessionId: 'session-1', + turnId: 'turn-1', + cwd, + permissionMode: 'ask', + toolCallId: `${name}-call`, + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }, + }) + ).claims; +} + +async function claims( + preparationService: ToolPreparationService, + tools: ReadonlyMap, + name: string, + input: unknown, + cwd: string, +): Promise { + const tool = tools.get(name); + if (!tool) throw new Error(`${name} is not registered`); + const context: MakaToolContext = { + sessionId: 'session-1', + turnId: 'turn-1', + cwd, + permissionMode: 'ask', + toolCallId: `${name}-call`, + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }; + const operation = await preparationService.prepare({ tool, input, ctx: context }); + return operation.claims; +} diff --git a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts index 26050bea7c..4975f034d7 100644 --- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts @@ -26,8 +26,9 @@ import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; -import { buildBuiltinTools } from '../builtin-tools.js'; +import { buildBuiltinToolComposition, buildBuiltinTools } from '../builtin-tools.js'; import type { FilesystemWorkerExecuteInput } from '../filesystem-worker/client.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; const cleanup: string[] = []; @@ -170,7 +171,7 @@ describe('builtin file tools use the sandboxed worker', () => { const cwd = await temporaryDirectory('maka-file-worker-cwd-'); const calls: FilesystemWorkerExecuteInput[] = []; let snapshotOwnerId: string | undefined; - const tools = buildBuiltinTools({ + const composition = buildBuiltinToolComposition({ filesystemWorker: { execute: async (input) => { calls.push(input); @@ -188,11 +189,49 @@ describe('builtin file tools use the sandboxed worker', () => { sandboxPlatform: 'darwin', }); - await runTool(tools, 'Read', { path: 'image.png', offset: 1, limit: 1 }, cwd); + const readTool = composition.tools.find((tool) => tool.name === 'Read'); + assert.ok(readTool, 'Read tool must be present in the builtin composition'); + assert.equal('prepare' in readTool, false, 'authority wiring must not leak onto MakaTool'); + const operation = await new ToolPreparationService(composition.authorityRegistry).prepare({ + tool: readTool, + input: { path: 'image.png', offset: 1, limit: 1 }, + ctx: { + sessionId: 'session-1', + turnId: 'turn-1', + toolCallId: 'tool-Read', + cwd, + permissionMode: 'ask', + executionBoundary: createManagedExecutionBoundary( + createWorkspaceWritePermissionProfile(), + 0, + ), + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }, + }); + assert.equal(operation.claims.length, 1); + + const result = await operation.execute(undefined, undefined, { + operationId: 'toolop-Read', + }); assert.equal(calls.length, 1); - assert.deepEqual(calls[0]?.operation, { kind: 'read', path: 'image.png', offset: 1, limit: 1 }); + assert.deepEqual(calls[0]?.operation, { + kind: 'read', + path: join(cwd, 'image.png'), + offset: 1, + limit: 1, + }); assert.equal(snapshotOwnerId, 'toolop-Read'); + assert.deepEqual(result, { + kind: 'image', + mimeType: 'image/png', + ref: { + kind: 'session_context', + sessionId: 'session-1', + refId: 'context-1', + }, + }); }); test('releases a Read image snapshot when durable result commit fails', async () => { @@ -260,13 +299,21 @@ describe('builtin file tools use the sandboxed worker', () => { ); }); - test('serializes writes through real and symlinked cwd paths', async () => { + test('serializes writes through real and symlinked cwd paths', async (t) => { const root = await temporaryDirectory('maka-file-lock-alias-'); const workspace = join(root, 'workspace'); const alias = join(root, 'workspace-alias'); await mkdir(workspace); await writeFile(join(workspace, 'shared.txt'), 'before', 'utf8'); - await symlink(workspace, alias, 'dir'); + try { + await symlink(workspace, alias, 'dir'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('Creating directory symlinks requires an elevated Windows token.'); + return; + } + throw error; + } let active = 0; let maxActive = 0; const calls: FilesystemWorkerExecuteInput[] = []; diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 6b666366e8..61fe547746 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -2246,8 +2246,13 @@ describe('builtin write tools path containment', () => { const writes: Array<{ cwd: string; path: string; content: string }> = []; const write = buildBuiltinTools({ executor: fakeExecutor({ - writeLockKey: async ({ cwd, path }) => ({ key: JSON.stringify([cwd, path]) }), - resolveWritablePath: async ({ cwd, path }) => ({ path: `${cwd}/${path}` }), + writeLockKey: async ({ cwd, path }) => ({ + key: JSON.stringify([cwd, path]), + canonicalPath: `${cwd}/${path}`, + }), + resolveWritablePath: async ({ cwd, path }) => ({ + path: path.startsWith('/') ? path : `${cwd}/${path}`, + }), writeFile: async ({ cwd, path, content }) => { writes.push({ cwd, path, content }); return { ok: true, path, bytes: Buffer.byteLength(content, 'utf8') }; @@ -2787,7 +2792,7 @@ function fakeExecutor(overrides: Partial): WorkspaceExecutor }), resolveExistingPath: async ({ path }) => ({ path }), resolveWritablePath: async ({ path }) => ({ path }), - writeLockKey: async ({ cwd, path }) => ({ key: `${cwd}:${path}` }), + writeLockKey: async ({ cwd, path }) => ({ key: `${cwd}:${path}`, canonicalPath: path }), globFiles: async () => ({ files: [] }), grepFiles: async () => ({ matches: [] }), }; diff --git a/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts b/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts new file mode 100644 index 0000000000..44720e90af --- /dev/null +++ b/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + EXPLICIT_ALL_TOOL_AUTHORITY_IDS, + EXPLICIT_NONE_TOOL_AUTHORITY_IDS, + defaultToolAuthorityRegistrations, +} from '../preparation/default-tool-authorities.js'; +import { ToolAuthorityRegistry } from '../preparation/tool-authority-registry.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; + +const INTERNALLY_CORRECT_TOOL_IDS = [ + 'StopBackgroundTask', + 'WriteStdin', + 'todo_read', + 'todo_write', + 'SearchHistory', + 'ReadHistory', + 'ScheduledTask', + 'GoalSet', + 'GoalClear', + 'GoalStatus', + 'GoalPause', + 'GoalResume', + 'SubmitPlan', + 'update_plan', + 'cancel_plan', +] as const; + +describe('domain tool authority fallbacks', () => { + test('temporarily maps internally-correct implementations to none()', async () => { + for (const name of INTERNALLY_CORRECT_TOOL_IDS) { + assert.equal(EXPLICIT_NONE_TOOL_AUTHORITY_IDS.includes(name), true, name); + assert.deepEqual(await claims(name), [], name); + } + }); + + test('maps immutable SkillSearch to none()', async () => { + assert.equal(EXPLICIT_NONE_TOOL_AUTHORITY_IDS.includes('SkillSearch'), true); + assert.deepEqual(await claims('SkillSearch'), []); + }); + + test('maps the Code Mode exec container to none()', async () => { + assert.equal(EXPLICIT_NONE_TOOL_AUTHORITY_IDS.includes('exec'), true); + assert.deepEqual(await claims('exec'), []); + }); + + test('keeps Computer fail-closed at all() until host/window ownership exists', async () => { + assert.equal(EXPLICIT_ALL_TOOL_AUTHORITY_IDS.includes('maka_computer'), true); + assert.deepEqual(await claims('maka_computer'), [{ kind: 'all' }]); + }); + + test('none() and all() both execute the real implementation exactly once', async () => { + for (const name of ['todo_write', 'SkillSearch', 'maka_computer']) { + let calls = 0; + const operation = await service().prepare({ + tool: tool(name, async () => { + calls += 1; + return name; + }), + input: {}, + ctx: context(), + }); + assert.equal(await operation.execute(), name); + await assert.rejects(operation.execute(), /already been executed/); + assert.equal(calls, 1); + } + }); +}); + +async function claims(name: string): Promise { + const operation = await service().prepare({ + tool: tool(name, async () => undefined), + input: {}, + ctx: context(), + }); + return operation.claims; +} + +function service(): ToolPreparationService { + return new ToolPreparationService(new ToolAuthorityRegistry(defaultToolAuthorityRegistrations())); +} + +function tool(name: string, impl: MakaTool['impl']): MakaTool { + return { name, description: 'domain authority fallback test tool', parameters: undefined, impl }; +} + +function context(): MakaToolContext { + return { + sessionId: 'session-1', + turnId: 'turn-1', + cwd: process.cwd(), + permissionMode: 'ask', + toolCallId: 'tool-call-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }; +} diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index b715b10c73..f20bb63466 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -26,13 +26,18 @@ import { type ToolResultArchiveServices, } from '../tool-result-archive-capability.js'; import { ToolRuntime, type ToolRuntimeInput } from '../tool-runtime.js'; +import { ToolAuthorityRegistry } from '../preparation/tool-authority-registry.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; export const readExternalExecutionBoundary: AiSdkBackendInput['readExecutionBoundary'] = async () => createExternalExecutionBoundary(); -type TestAiSdkBackendInput = Omit & - Partial> & { +type TestAiSdkBackendInput = Omit< + AiSdkBackendInput, + 'readExecutionBoundary' | 'preparationService' +> & + Partial> & { testProjectionArtifacts?: boolean; }; @@ -47,6 +52,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const transitions: ModelProjectionTransition[] = []; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + preparationService: new ToolPreparationService(new ToolAuthorityRegistry()), loadModelProjectionTransitions: async () => ({ transitions: [...transitions], unreadableTargets: new Set(), diff --git a/packages/runtime/src/__tests__/file-write-lock.test.ts b/packages/runtime/src/__tests__/file-write-lock.test.ts index 15d34ca0d9..9d6a0e0397 100644 --- a/packages/runtime/src/__tests__/file-write-lock.test.ts +++ b/packages/runtime/src/__tests__/file-write-lock.test.ts @@ -25,6 +25,9 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { withFileWriteLock } from '../file-write-lock.js'; +import { processFilesystemLeases } from '../filesystem-lease-coordinator.js'; +import { hostFilesystemLeaseKey } from '../filesystem-lease-key.js'; +import { processResourceAdmissions } from '../process-resource-admission.js'; const tick = () => new Promise((r) => setImmediate(r)); @@ -86,4 +89,43 @@ describe('withFileWriteLock', () => { assert.equal(after, 'ok'); assert.deepEqual(order, ['fail', 'after']); }); + + test('shares the default coordinator namespace with read leases', async () => { + let releaseRead!: () => void; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + let writeRan = false; + const read = processFilesystemLeases.withLease( + { key: hostFilesystemLeaseKey('adapter-visible'), mode: 'read', scope: 'exact' }, + undefined, + async () => await readGate, + ); + const write = withFileWriteLock('adapter-visible', async () => { + writeRan = true; + }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(writeRan, false); + releaseRead(); + await Promise.all([read, write]); + assert.equal(writeRan, true); + }); + + test('participates in the process-wide all() barrier', async () => { + let releaseAll!: () => void; + const allGate = new Promise((resolve) => { + releaseAll = resolve; + }); + let writeRan = false; + const all = processResourceAdmissions.withExclusive(undefined, async () => await allGate); + const write = withFileWriteLock('process-visible', async () => { + writeRan = true; + }); + await Promise.resolve(); + assert.equal(writeRan, false); + releaseAll(); + await Promise.all([all, write]); + assert.equal(writeRan, true); + }); }); diff --git a/packages/runtime/src/__tests__/filesystem-admission-identity.test.ts b/packages/runtime/src/__tests__/filesystem-admission-identity.test.ts new file mode 100644 index 0000000000..cb04ad5ad3 --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-admission-identity.test.ts @@ -0,0 +1,503 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { + createBoundaryFilesystemExecutor, + createFilesystemResourceOwner, + type FilesystemResult, +} from '../filesystem-executor.js'; +import { createFilesystemLeaseCoordinator } from '../filesystem-lease-coordinator.js'; +import { hostFilesystemLeaseKey } from '../filesystem-lease-key.js'; +import type { FilesystemWorkerExecuteInput } from '../filesystem-worker/client.js'; +import { processAllOperation } from '../preparation/placeholder-authorities.js'; +import type { AuthorityContext, PreparedOperation } from '../preparation/types.js'; +import { createProcessResourceAdmissionCoordinator } from '../process-resource-admission.js'; +import { settleToolCallBatch } from '../tool-call-batch.js'; +import { createLocalWorkspaceExecutor } from '../workspace-executor.js'; + +test('prepared Write(create) then Read observes the admitted post-write identity', async (t) => { + const cwd = await temporaryDirectory(t); + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const write = await prepare(owner, cwd, 'write', { + operation: { kind: 'write', path: 'created.txt', content: 'created' }, + cwd, + }); + const read = await prepare(owner, cwd, 'read', { + operation: { kind: 'read', path: 'created.txt' }, + cwd, + }); + + const writeResult = (await write.execute()) as FilesystemResult; + const readResult = (await read.execute()) as FilesystemResult; + + assert.equal(writeResult.kind, 'write'); + assert.deepEqual(readResult, { kind: 'read', content: 'created' }); + assert.equal(await readFile(join(cwd, 'created.txt'), 'utf8'), 'created'); +}); + +test('prepared Write(existing) then Read observes the new content', async (t) => { + const cwd = await temporaryDirectory(t); + await writeFile(join(cwd, 'file.txt'), 'before', 'utf8'); + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const write = await prepare(owner, cwd, 'write', { + operation: { kind: 'write', path: 'file.txt', content: 'after' }, + cwd, + }); + const read = await prepare(owner, cwd, 'read', { + operation: { kind: 'read', path: 'file.txt' }, + cwd, + }); + + await write.execute(); + assert.deepEqual(await read.execute(), { kind: 'read', content: 'after' }); +}); + +test('independent direct batches share admission-relative identity through the coordinator', async (t) => { + const cwd = await temporaryDirectory(t); + let releaseWrite!: () => void; + let markWriteAdmitted!: () => void; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + const writeAdmitted = new Promise((resolve) => { + markWriteAdmitted = resolve; + }); + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + beforeTargetEffectForTest: async (target) => { + if (target.identity.kind !== 'missing') return; + markWriteAdmitted(); + await writeGate; + }, + }); + const write = owner.executor.execute({ + operation: { kind: 'write', path: 'created.txt', content: 'created' }, + cwd, + }); + await writeAdmitted; + const read = owner.executor.execute({ operation: { kind: 'read', path: 'created.txt' }, cwd }); + + releaseWrite(); + await write; + assert.deepEqual(await read, { kind: 'read', content: 'created' }); +}); + +test('real ToolCallBatch Write(create) then Read observes the created file', async (t) => { + const cwd = await temporaryDirectory(t); + const processAdmission = createProcessResourceAdmissionCoordinator(); + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + processResourceAdmissionCoordinator: processAdmission, + }); + const outcomes = await settleToolCallBatch( + [ + { + id: 'write', + prepare: async () => + await prepare(owner, cwd, 'write', { + operation: { kind: 'write', path: 'created.txt', content: 'created' }, + cwd, + }), + run: async (operation) => await operation?.execute(), + }, + { + id: 'read', + prepare: async () => + await prepare(owner, cwd, 'read', { + operation: { kind: 'read', path: 'created.txt' }, + cwd, + }), + run: async (operation) => await operation?.execute(), + }, + ], + { processAdmission }, + ); + + assert.deepEqual( + outcomes.map((outcome) => outcome.status), + ['fulfilled', 'fulfilled'], + ); + assert.deepEqual(outcomes[1], { + status: 'fulfilled', + value: { kind: 'read', content: 'created' }, + }); + assert.equal(await readFile(join(cwd, 'created.txt'), 'utf8'), 'created'); +}); + +test('independent ToolCallBatches: active all blocks real Write and Read disk effects', async (t) => { + const cwd = await temporaryDirectory(t); + const target = join(cwd, 'created.txt'); + let releaseAll!: () => void; + let markAllStarted!: () => void; + let markSharedQueued!: () => void; + const allGate = new Promise((resolve) => { + releaseAll = resolve; + }); + t.after(() => releaseAll()); + const allStarted = new Promise((resolve) => { + markAllStarted = resolve; + }); + const sharedQueued = new Promise((resolve) => { + markSharedQueued = resolve; + }); + const processAdmission = createProcessResourceAdmissionCoordinator({ + onTransition: (transition) => { + if ( + transition.process_admission_mode === 'shared' && + transition.process_admission_state === 'queued' + ) { + markSharedQueued(); + } + }, + }); + let filesystemEffects = 0; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + processResourceAdmissionCoordinator: processAdmission, + beforeTargetEffectForTest: () => { + filesystemEffects += 1; + }, + }); + const allBatch = settleToolCallBatch( + [ + { + id: 'session-a-all', + prepare: async () => + processAllOperation(async () => { + markAllStarted(); + await allGate; + return 'all'; + }, processAdmission), + run: async (operation) => await operation?.execute(), + }, + ], + { processAdmission }, + ); + await allStarted; + const filesystemBatch = settleToolCallBatch( + [ + { + id: 'session-b-write', + prepare: async () => + await prepare(owner, cwd, 'session-b-write', { + operation: { kind: 'write', path: 'created.txt', content: 'created' }, + cwd, + }), + run: async (operation) => await operation?.execute(), + }, + { + id: 'session-b-read', + prepare: async () => + await prepare(owner, cwd, 'session-b-read', { + operation: { kind: 'read', path: 'created.txt' }, + cwd, + }), + run: async (operation) => await operation?.execute(), + }, + ], + { processAdmission }, + ); + + await sharedQueued; + assert.equal(filesystemEffects, 0, 'neither real filesystem effect may pass active all()'); + await assert.rejects(readFile(target, 'utf8'), { code: 'ENOENT' }); + + releaseAll(); + assert.deepEqual(await allBatch, [{ status: 'fulfilled', value: 'all' }]); + const outcomes = await filesystemBatch; + assert.deepEqual( + outcomes.map((outcome) => outcome.status), + ['fulfilled', 'fulfilled'], + ); + assert.deepEqual(outcomes[1], { + status: 'fulfilled', + value: { kind: 'read', content: 'created' }, + }); + assert.equal(filesystemEffects, 2); + assert.equal(await readFile(target, 'utf8'), 'created'); +}); + +test('an atomic replacement owner then Read accepts the identity current at admission', async (t) => { + const cwd = await temporaryDirectory(t); + const target = join(cwd, 'file.txt'); + const replacement = join(cwd, 'replacement.txt'); + await writeFile(target, 'before', 'utf8'); + await writeFile(replacement, 'replacement', 'utf8'); + const coordinator = createFilesystemLeaseCoordinator(); + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: coordinator, + }); + const read = await prepare(owner, cwd, 'read', { + operation: { kind: 'read', path: 'file.txt' }, + cwd, + }); + + await coordinator.withLease( + { + key: hostFilesystemLeaseKey(target), + mode: 'write', + scope: 'exact', + }, + undefined, + async () => await rename(replacement, target), + ); + + assert.deepEqual(await read.execute(), { kind: 'read', content: 'replacement' }); + assert.equal(await readFile(target, 'utf8'), 'replacement'); +}); + +test('ApplyPatch captures identity per operation for create then same-key update', async (t) => { + const cwd = await temporaryDirectory(t); + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + + const result = await filesystem.applyPatchBatch({ + cwd, + operations: [ + { type: 'create_file', path: 'file.txt', diff: '+created\n' }, + { type: 'update_file', path: 'file.txt', diff: '@@\n-created\n+updated\n' }, + ], + }); + + assert.equal(result.status, 'completed'); + assert.equal(await readFile(join(cwd, 'file.txt'), 'utf8'), 'updated'); + assert.deepEqual( + await filesystem.execute({ operation: { kind: 'read', path: 'file.txt' }, cwd }), + { kind: 'read', content: 'updated' }, + ); +}); + +test('prepared ApplyPatch(create) then Read observes the created inode', async (t) => { + const cwd = await temporaryDirectory(t); + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const abortSignal = new AbortController().signal; + const patch = await owner.authority.preparePatchBatch( + [{ type: 'create_file', path: 'file.txt', diff: '+patched\n' }], + authorityContext(cwd, 'patch', abortSignal), + ); + const read = await prepare(owner, cwd, 'read', { + operation: { kind: 'read', path: 'file.txt' }, + cwd, + }); + + assert.equal((await patch.execute(abortSignal)).status, 'completed'); + assert.deepEqual(await read.execute(), { kind: 'read', content: 'patched' }); +}); + +test('prepared delete then create then Read observes the final logical path state', async (t) => { + const cwd = await temporaryDirectory(t); + await writeFile(join(cwd, 'file.txt'), 'old', 'utf8'); + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const remove = await prepare(owner, cwd, 'delete', { + operation: { type: 'delete_file', path: 'file.txt' }, + cwd, + }); + const create = await prepare(owner, cwd, 'create', { + operation: { type: 'create_file', path: 'file.txt', diff: '+new\n' }, + cwd, + }); + const read = await prepare(owner, cwd, 'read', { + operation: { kind: 'read', path: 'file.txt' }, + cwd, + }); + + await remove.execute(); + await create.execute(); + assert.deepEqual(await read.execute(), { kind: 'read', content: 'new' }); +}); + +test('claim drift after prepare fails before the backend effect starts', async (t) => { + const root = await temporaryDirectory(t); + const first = join(root, 'first'); + const second = join(root, 'second'); + const alias = join(root, 'alias'); + await Promise.all([mkdir(first), mkdir(second)]); + await Promise.all([ + writeFile(join(first, 'file.txt'), 'first', 'utf8'), + writeFile(join(second, 'file.txt'), 'second', 'utf8'), + ]); + await symlink(first, alias, process.platform === 'win32' ? 'junction' : 'dir'); + let effects = 0; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + beforeTargetEffectForTest: () => { + effects += 1; + }, + }); + const read = await prepare(owner, alias, 'read', { + operation: { kind: 'read', path: 'file.txt' }, + cwd: alias, + }); + + await unlink(alias); + await symlink(second, alias, process.platform === 'win32' ? 'junction' : 'dir'); + + await assert.rejects(read.execute(), { code: 'filesystem_prepared_claim_changed' }); + assert.equal(effects, 0); +}); + +test('pinned Read rejects replacement after admission and never returns replacement content', async (t) => { + const cwd = await temporaryDirectory(t); + const target = join(cwd, 'file.txt'); + const replacement = join(cwd, 'replacement.txt'); + await writeFile(target, 'original', 'utf8'); + await writeFile(replacement, 'replacement', 'utf8'); + let swapped = false; + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + beforeTargetEffectForTest: async () => { + if (swapped) return; + swapped = true; + await rename(replacement, target); + }, + }); + + await assert.rejects(filesystem.execute({ operation: { kind: 'read', path: 'file.txt' }, cwd }), { + code: 'path_changed', + }); + assert.equal(await readFile(target, 'utf8'), 'replacement'); +}); + +test('pinned Write rejects replacement after admission without modifying it', async (t) => { + const cwd = await temporaryDirectory(t); + const target = join(cwd, 'file.txt'); + const replacement = join(cwd, 'replacement.txt'); + await writeFile(target, 'original', 'utf8'); + await writeFile(replacement, 'replacement', 'utf8'); + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + beforeTargetEffectForTest: async () => await rename(replacement, target), + }); + + await assert.rejects( + filesystem.execute({ operation: { kind: 'write', path: 'file.txt', content: 'changed' }, cwd }), + { code: 'path_changed' }, + ); + assert.equal(await readFile(target, 'utf8'), 'replacement'); +}); + +test('compare-and-delete rejects replacement after admission without deleting it', async (t) => { + const cwd = await temporaryDirectory(t); + const target = join(cwd, 'file.txt'); + const replacement = join(cwd, 'replacement.txt'); + await writeFile(target, 'original', 'utf8'); + await writeFile(replacement, 'replacement', 'utf8'); + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + beforeTargetEffectForTest: async () => await rename(replacement, target), + }); + + await assert.rejects( + filesystem.applyPatch({ operation: { type: 'delete_file', path: 'file.txt' }, cwd }), + { code: 'path_changed' }, + ); + assert.equal(await readFile(target, 'utf8'), 'replacement'); +}); + +test('worker-backed exact Read receives the admission identity', async (t) => { + const cwd = await temporaryDirectory(t); + await writeFile(join(cwd, 'file.txt'), 'content', 'utf8'); + let received: FilesystemWorkerExecuteInput['expectedIdentity']; + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + worker: { + async execute(input) { + received = input.expectedIdentity; + return { kind: 'read', content: 'content' }; + }, + }, + }); + + assert.deepEqual( + await filesystem.execute({ operation: { kind: 'read', path: 'file.txt' }, cwd }), + { kind: 'read', content: 'content' }, + ); + assert.ok(received && typeof received === 'object'); +}); + +async function temporaryDirectory(t: TestContext): Promise { + const path = await realpath(await mkdtemp(join(tmpdir(), 'maka-admission-identity-'))); + t.after(() => rm(path, { recursive: true, force: true })); + return path; +} + +async function prepare( + owner: ReturnType, + cwd: string, + toolCallId: string, + input: Parameters[0], +): Promise> { + const abortSignal = new AbortController().signal; + const context: AuthorityContext = { + ...authorityContext(cwd, toolCallId, abortSignal), + }; + return await owner.authority.prepare({ ...input, abortSignal }, context); +} + +function authorityContext( + cwd: string, + toolCallId: string, + abortSignal: AbortSignal, +): AuthorityContext { + return { + sessionId: 'session', + turnId: 'turn', + toolCallId, + cwd, + abortSignal, + }; +} diff --git a/packages/runtime/src/__tests__/filesystem-apply-patch.test.ts b/packages/runtime/src/__tests__/filesystem-apply-patch.test.ts index f43d4c3cdd..d37191c0ce 100644 --- a/packages/runtime/src/__tests__/filesystem-apply-patch.test.ts +++ b/packages/runtime/src/__tests__/filesystem-apply-patch.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test, type TestContext } from 'node:test'; @@ -43,12 +43,17 @@ test('applies one native update operation through the filesystem authority', asy assert.equal(await readFile(join(cwd, 'file.txt'), 'utf8'), 'after\n'); }); -test('deletes a self-referential symlink entry without following it', { - skip: process.platform === 'win32', -}, async (t) => { +test('deletes a link entry without following it', async (t) => { const cwd = await temporaryDirectory(t); const link = join(cwd, 'loop'); - await symlink('loop', link); + const junctionTarget = join(cwd, 'junction-target'); + if (process.platform === 'win32') { + await mkdir(junctionTarget); + await writeFile(join(junctionTarget, 'keep.txt'), 'keep', 'utf8'); + await symlink(junctionTarget, link, 'junction'); + } else { + await symlink('loop', link); + } const filesystem = localFilesystem(); assert.deepEqual( @@ -58,7 +63,10 @@ test('deletes a self-referential symlink entry without following it', { }), { status: 'completed' }, ); - await assert.rejects(readFile(link, 'utf8'), { code: 'ENOENT' }); + await assert.rejects(lstat(link), { code: 'ENOENT' }); + if (process.platform === 'win32') { + assert.equal(await readFile(join(junctionTarget, 'keep.txt'), 'utf8'), 'keep'); + } }); test('creates nested files exclusively', async (t) => { diff --git a/packages/runtime/src/__tests__/filesystem-authority-leases.test.ts b/packages/runtime/src/__tests__/filesystem-authority-leases.test.ts new file mode 100644 index 0000000000..9237af5381 --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-authority-leases.test.ts @@ -0,0 +1,508 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { buildBuiltinToolComposition } from '../builtin-tools.js'; +import { createFilesystemResourceOwner, type FilesystemResult } from '../filesystem-executor.js'; +import { createFilesystemLeaseCoordinator } from '../filesystem-lease-coordinator.js'; +import type { FilesystemWorkerExecuteInput } from '../filesystem-worker/client.js'; +import type { FilesystemWorkerResult } from '../filesystem-worker/protocol.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; +import type { AuthorityContext } from '../preparation/types.js'; +import { createProcessResourceAdmissionCoordinator } from '../process-resource-admission.js'; +import { createLocalWorkspaceExecutor } from '../workspace-executor.js'; + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +test('direct filesystem execution participates in the process-wide all() barrier', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-process-fs-'))); + try { + await writeFile(join(cwd, 'shared.txt'), 'before', 'utf8'); + const processAdmission = createProcessResourceAdmissionCoordinator(); + const releaseAll = deferred(); + const allStarted = deferred(); + const readStarted = deferred(); + let workerCalls = 0; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + processResourceAdmissionCoordinator: processAdmission, + worker: { + async execute(input: FilesystemWorkerExecuteInput): Promise { + workerCalls += 1; + assert.equal(input.operation.kind, 'read'); + readStarted.resolve(); + return { kind: 'read', content: 'before' }; + }, + }, + }); + const all = processAdmission.withExclusive(undefined, async () => { + allStarted.resolve(); + await releaseAll.promise; + }); + await allStarted.promise; + const read = owner.executor.execute({ + operation: { kind: 'read', path: 'shared.txt' }, + cwd, + }); + await Promise.resolve(); + assert.equal(workerCalls, 0, 'the filesystem worker must remain behind active all()'); + + releaseAll.resolve(); + await all; + await readStarted.promise; + assert.equal((await read).kind, 'read'); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('active direct filesystem execution blocks a later all() holder', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-fs-process-'))); + try { + await writeFile(join(cwd, 'shared.txt'), 'before', 'utf8'); + const processAdmission = createProcessResourceAdmissionCoordinator(); + const readStarted = deferred(); + const releaseRead = deferred(); + let allStarted = false; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + processResourceAdmissionCoordinator: processAdmission, + worker: { + async execute(input: FilesystemWorkerExecuteInput): Promise { + assert.equal(input.operation.kind, 'read'); + readStarted.resolve(); + await releaseRead.promise; + return { kind: 'read', content: 'before' }; + }, + }, + }); + const read = owner.executor.execute({ + operation: { kind: 'read', path: 'shared.txt' }, + cwd, + }); + await readStarted.promise; + const all = processAdmission.withExclusive(undefined, async () => { + allStarted = true; + }); + assert.equal(allStarted, false); + + releaseRead.resolve(); + await read; + assert.equal(allStarted, true); + await all; + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('direct and prepared filesystem operations share the owner lease without Scheduler help', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-authority-leases-'))); + try { + const target = join(cwd, 'shared.txt'); + await writeFile(target, 'before', 'utf8'); + const readStarted = deferred(); + const releaseRead = deferred(); + const calls: FilesystemWorkerExecuteInput[] = []; + const worker = { + async execute(input: FilesystemWorkerExecuteInput): Promise { + calls.push(input); + if (input.operation.kind === 'read') { + readStarted.resolve(); + await releaseRead.promise; + return { kind: 'read', content: 'before' }; + } + if (input.operation.kind === 'write') { + return { kind: 'write', ok: true, path: input.operation.path, bytes: 5 }; + } + throw new Error(`Unexpected operation ${input.operation.kind}`); + }, + }; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + worker, + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const abortSignal = new AbortController().signal; + const context: AuthorityContext = { + sessionId: 'session', + turnId: 'turn', + toolCallId: 'read', + cwd, + abortSignal, + }; + const prepared = await owner.authority.prepare( + { operation: { kind: 'read', path: 'shared.txt' }, cwd, abortSignal }, + context, + ); + assert.deepEqual(prepared.claims, [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: process.platform === 'win32' ? target.toUpperCase() : target, + mode: 'read', + scope: 'exact', + }, + ]); + + const read = prepared.execute(abortSignal) as Promise; + await readStarted.promise; + const write = owner.executor.execute({ + operation: { kind: 'write', path: 'shared.txt', content: 'after' }, + cwd, + abortSignal, + }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(calls.length, 1, 'the conflicting direct write must remain queued'); + assert.equal(calls[0]?.operation.path, target, 'the backend receives the canonical path'); + + releaseRead.resolve(); + await read; + const writeResult = await write; + assert.equal(writeResult.kind, 'write'); + assert.equal(calls.length, 2); + assert.equal(calls[1]?.operation.path, target); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('prepared Read blocks a direct same-file Edit without Scheduler help', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-read-edit-leases-'))); + try { + const target = join(cwd, 'shared.txt'); + await writeFile(target, 'before', 'utf8'); + const readStarted = deferred(); + const releaseRead = deferred(); + const calls: string[] = []; + const worker = { + async execute(input: FilesystemWorkerExecuteInput): Promise { + calls.push(input.operation.kind); + if (input.operation.kind === 'read') { + readStarted.resolve(); + await releaseRead.promise; + return { kind: 'read', content: 'before' }; + } + if (input.operation.kind === 'edit') { + return { + kind: 'edit', + ok: true, + path: input.operation.path, + replacements: 1, + matchedVia: 'exact', + startLine: 1, + endLine: 1, + }; + } + throw new Error(`Unexpected operation ${input.operation.kind}`); + }, + }; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + worker, + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const abortSignal = new AbortController().signal; + const prepared = await owner.authority.prepare( + { operation: { kind: 'read', path: 'shared.txt' }, cwd, abortSignal }, + { + sessionId: 'session', + turnId: 'turn', + toolCallId: 'read', + cwd, + abortSignal, + }, + ); + + const read = prepared.execute(abortSignal); + await readStarted.promise; + const edit = owner.executor.execute({ + operation: { + kind: 'edit', + path: 'shared.txt', + oldString: 'before', + newString: 'after', + }, + cwd, + abortSignal, + }); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(calls, ['read']); + + releaseRead.resolve(); + await Promise.all([read, edit]); + assert.deepEqual(calls, ['read', 'edit']); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('structured single-operation patch blocks overlapping Read and Grep directly', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-structured-patch-leases-'))); + try { + await mkdir(join(cwd, 'src'), { recursive: true }); + await writeFile(join(cwd, 'src', 'a.txt'), 'before', 'utf8'); + const patchStarted = deferred(); + const releasePatch = deferred(); + const calls: string[] = []; + const worker = { + async execute(input: FilesystemWorkerExecuteInput): Promise { + calls.push(input.operation.kind); + if (input.operation.kind === 'apply_patch') { + patchStarted.resolve(); + await releasePatch.promise; + return { kind: 'apply_patch', ok: true, path: input.operation.path }; + } + if (input.operation.kind === 'read') { + return { kind: 'read', content: 'after' }; + } + if (input.operation.kind === 'grep') { + return { kind: 'grep', matches: [] }; + } + throw new Error(`Unexpected operation ${input.operation.kind}`); + }, + }; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + worker, + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const patch = owner.executor.applyPatch({ + operation: { type: 'update_file', path: 'src/a.txt', diff: '@@' }, + cwd, + }); + await patchStarted.promise; + const read = owner.executor.execute({ operation: { kind: 'read', path: 'src/a.txt' }, cwd }); + const grep = owner.executor.execute({ + operation: { + kind: 'grep', + path: 'src', + pattern: 'after', + maxCountPerFile: 100, + limit: 100, + timeoutMs: 2_000, + }, + cwd, + }); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(calls, ['apply_patch']); + + releasePatch.resolve(); + await Promise.all([patch, read, grep]); + assert.equal(calls[0], 'apply_patch'); + assert.deepEqual(calls.slice(1).sort(), ['grep', 'read']); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('prepared junction or symlink aliases share the canonical lease key', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-prepared-alias-leases-'))); + try { + const cwd = join(root, 'workspace'); + const alias = join(root, 'workspace-alias'); + await mkdir(cwd, { recursive: true }); + await writeFile(join(cwd, 'shared.txt'), 'before', 'utf8'); + await symlink(cwd, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const readStarted = deferred(); + const releaseRead = deferred(); + const calls: string[] = []; + const worker = { + async execute(input: FilesystemWorkerExecuteInput): Promise { + calls.push(input.operation.kind); + if (input.operation.kind === 'read') { + readStarted.resolve(); + await releaseRead.promise; + return { kind: 'read', content: 'before' }; + } + if (input.operation.kind === 'edit') { + return { + kind: 'edit', + ok: true, + path: input.operation.path, + replacements: 1, + matchedVia: 'exact', + startLine: 1, + endLine: 1, + }; + } + throw new Error(`Unexpected operation ${input.operation.kind}`); + }, + }; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + worker, + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const abortSignal = new AbortController().signal; + const prepared = await owner.authority.prepare( + { operation: { kind: 'read', path: 'shared.txt' }, cwd: alias, abortSignal }, + { + sessionId: 'session', + turnId: 'turn', + toolCallId: 'aliased-read', + cwd: alias, + abortSignal, + }, + ); + const read = prepared.execute(abortSignal); + await readStarted.promise; + const edit = owner.executor.execute({ + operation: { + kind: 'edit', + path: 'shared.txt', + oldString: 'before', + newString: 'after', + }, + cwd, + abortSignal, + }); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(calls, ['read']); + + releaseRead.resolve(); + await Promise.all([read, edit]); + assert.deepEqual(calls, ['read', 'edit']); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('multi-file patch holds every target lease as one interval while unrelated files fan out', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-patch-leases-'))); + try { + const a = join(cwd, 'a.txt'); + const b = join(cwd, 'b.txt'); + const c = join(cwd, 'c.txt'); + await Promise.all([ + writeFile(a, 'a', 'utf8'), + writeFile(b, 'b', 'utf8'), + writeFile(c, 'c', 'utf8'), + ]); + const patchAStarted = deferred(); + const releasePatchA = deferred(); + const readCStarted = deferred(); + const calls: string[] = []; + const worker = { + async execute(input: FilesystemWorkerExecuteInput): Promise { + const label = `${input.operation.kind}:${input.operation.path}`; + calls.push(label); + if (input.operation.kind === 'apply_patch') { + if (input.operation.path === a) { + patchAStarted.resolve(); + await releasePatchA.promise; + } + return { kind: 'apply_patch', ok: true, path: input.operation.path }; + } + if (input.operation.kind === 'read') { + if (input.operation.path === c) readCStarted.resolve(); + return { kind: 'read', content: input.operation.path }; + } + throw new Error(`Unexpected operation ${input.operation.kind}`); + }, + }; + const owner = createFilesystemResourceOwner({ + workspace: createLocalWorkspaceExecutor(), + worker, + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const batch = owner.executor.applyPatchBatch({ + cwd, + operations: [ + { type: 'update_file', path: 'a.txt', diff: '@@' }, + { type: 'update_file', path: 'b.txt', diff: '@@' }, + ], + }); + await patchAStarted.promise; + const readB = owner.executor.execute({ operation: { kind: 'read', path: 'b.txt' }, cwd }); + const readC = owner.executor.execute({ operation: { kind: 'read', path: 'c.txt' }, cwd }); + await readCStarted.promise; + assert.deepEqual(calls, [`apply_patch:${a}`, `read:${c}`]); + + releasePatchA.resolve(); + const batchResult = await batch; + assert.equal(batchResult.status, 'completed'); + await Promise.all([readB, readC]); + assert.deepEqual(calls, [`apply_patch:${a}`, `read:${c}`, `apply_patch:${b}`, `read:${b}`]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('freeform multi-file patch prepare emits sorted exact-write claims instead of all()', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-patch-claims-'))); + try { + const composition = buildBuiltinToolComposition({ + filesystemLeaseCoordinator: createFilesystemLeaseCoordinator(), + }); + const tool = composition.tools.find((candidate) => candidate.name === 'apply_patch'); + assert.ok(tool); + const abortSignal = new AbortController().signal; + const operation = await new ToolPreparationService(composition.authorityRegistry).prepare({ + tool, + input: [ + '*** Begin Patch', + '*** Add File: b.txt', + '+b', + '*** Add File: a.txt', + '+a', + '*** End Patch', + ].join('\n'), + ctx: { + sessionId: 'session', + turnId: 'turn', + toolCallId: 'patch', + cwd, + abortSignal, + emitOutput: () => {}, + }, + }); + const expected = [join(cwd, 'a.txt'), join(cwd, 'b.txt')].map((path) => + process.platform === 'win32' ? path.toUpperCase() : path, + ); + assert.deepEqual( + operation.claims.map((claim) => + claim.kind === 'keyed' + ? { key: claim.key, mode: claim.mode, scope: claim.scope } + : { kind: claim.kind }, + ), + expected.map((key) => ({ key, mode: 'write', scope: 'exact' })), + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/src/__tests__/filesystem-authority.test.ts b/packages/runtime/src/__tests__/filesystem-authority.test.ts index ed20bbbe63..1e0caefee4 100644 --- a/packages/runtime/src/__tests__/filesystem-authority.test.ts +++ b/packages/runtime/src/__tests__/filesystem-authority.test.ts @@ -178,20 +178,26 @@ describe('file tools follow the execution boundary', () => { } }); - test('a symlink out of the cwd stays an escape under a workspace boundary', async () => { + test('a symlink or junction out of the cwd stays an escape under a workspace boundary', async () => { const { cwd, outside, cleanup } = await makeDirs(); try { const tools = toolsFor(); await writeFile(join(outside, 'secret.txt'), 'secret', 'utf8'); - await symlink(join(outside, 'secret.txt'), join(cwd, 'link.txt')); + const link = process.platform === 'win32' ? 'outside-link' : 'link.txt'; + const target = process.platform === 'win32' ? `${link}/secret.txt` : link; + await symlink( + process.platform === 'win32' ? outside : join(outside, 'secret.txt'), + join(cwd, link), + process.platform === 'win32' ? 'junction' : 'file', + ); await assert.rejects( - runTool(toolNamed(tools, 'Read'), { path: 'link.txt' }, cwd), + runTool(toolNamed(tools, 'Read'), { path: target }, cwd), /Read path must stay inside session cwd/, ); // Under bypass the same link resolves, because nothing is being escaped. assert.deepStrictEqual( - await runTool(toolNamed(tools, 'Read'), { path: 'link.txt' }, cwd, BYPASS), + await runTool(toolNamed(tools, 'Read'), { path: target }, cwd, BYPASS), { content: 'secret', }, diff --git a/packages/runtime/src/__tests__/filesystem-lease-coordinator.test.ts b/packages/runtime/src/__tests__/filesystem-lease-coordinator.test.ts new file mode 100644 index 0000000000..538a9a8c60 --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-lease-coordinator.test.ts @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + createFilesystemLeaseCoordinator, + filesystemLeaseRequestsConflict, + normalizeFilesystemLeaseRequests, + type FilesystemLeaseMode, + type FilesystemLeaseRequest, + type FilesystemLeaseScope, +} from '../filesystem-lease-coordinator.js'; + +function request( + key: string, + mode: FilesystemLeaseMode, + scope: FilesystemLeaseScope = 'exact', +): FilesystemLeaseRequest { + return { key, mode, scope }; +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (reason: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushEffects(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('FilesystemLeaseCoordinator', () => { + it('implements exact/tree read-write conflict semantics', () => { + assert.equal( + filesystemLeaseRequestsConflict(request('src/a', 'read'), request('src/a', 'read')), + false, + ); + assert.equal( + filesystemLeaseRequestsConflict(request('src', 'read', 'tree'), request('src/a', 'write')), + true, + ); + assert.equal( + filesystemLeaseRequestsConflict(request('src', 'read', 'tree'), request('src2/a', 'write')), + false, + ); + assert.equal( + filesystemLeaseRequestsConflict( + request('src', 'write', 'tree'), + request('src/sub', 'read', 'tree'), + ), + true, + ); + }); + + it('runs same-key writes in submission order and releases after rejection', async () => { + const coordinator = createFilesystemLeaseCoordinator(); + const gate = deferred(); + const events: string[] = []; + const first = coordinator.withLease(request('a', 'write'), undefined, async () => { + events.push('first:start'); + await gate.promise; + events.push('first:end'); + throw new Error('expected'); + }); + const second = coordinator.withLease(request('a', 'write'), undefined, async () => { + events.push('second:start'); + return 2; + }); + await flushEffects(); + assert.deepEqual(events, ['first:start']); + gate.resolve(); + await assert.rejects(first, /expected/); + assert.deepEqual( + events, + ['first:start', 'first:end', 'second:start'], + 'drain starts the successor before the first rejection is observable', + ); + assert.equal(await second, 2); + assert.deepEqual(events, ['first:start', 'first:end', 'second:start']); + }); + + it('allows reads and independent paths to fan out', async () => { + const coordinator = createFilesystemLeaseCoordinator(); + const gate = deferred(); + const started: string[] = []; + const work = [ + coordinator.withLease(request('a', 'read'), undefined, async () => { + started.push('read-1'); + await gate.promise; + }), + coordinator.withLease(request('a', 'read'), undefined, async () => { + started.push('read-2'); + await gate.promise; + }), + coordinator.withLease(request('b', 'write'), undefined, async () => { + started.push('write-b'); + await gate.promise; + }), + ]; + await flushEffects(); + assert.deepEqual(started, ['read-1', 'read-2', 'write-b']); + gate.resolve(); + await Promise.all(work); + }); + + it('enforces tree boundaries and writer fairness', async () => { + const coordinator = createFilesystemLeaseCoordinator(); + const readerGate = deferred(); + const writerGate = deferred(); + const started: string[] = []; + const reader = coordinator.withLease(request('src', 'read', 'tree'), undefined, async () => { + started.push('tree-reader'); + await readerGate.promise; + }); + const writer = coordinator.withLease(request('src/a', 'write'), undefined, async () => { + started.push('writer'); + await writerGate.promise; + }); + const laterReader = coordinator.withLease(request('src/a', 'read'), undefined, async () => { + started.push('later-reader'); + }); + const src2Writer = coordinator.withLease(request('src2/a', 'write'), undefined, async () => { + started.push('src2-writer'); + }); + await flushEffects(); + assert.deepEqual(started, ['tree-reader', 'src2-writer']); + readerGate.resolve(); + await reader; + await flushEffects(); + assert.deepEqual(started, ['tree-reader', 'src2-writer', 'writer']); + writerGate.resolve(); + await Promise.all([writer, laterReader, src2Writer]); + assert.deepEqual(started, ['tree-reader', 'src2-writer', 'writer', 'later-reader']); + }); + + it('removes an aborted queued waiter without running its effect', async () => { + const coordinator = createFilesystemLeaseCoordinator(); + const gate = deferred(); + const controller = new AbortController(); + let canceledRan = false; + let afterRan = false; + const active = coordinator.withLease(request('a', 'read'), undefined, async () => { + await gate.promise; + }); + const canceled = coordinator.withLease(request('a', 'write'), controller.signal, async () => { + canceledRan = true; + }); + const after = coordinator.withLease(request('a', 'read'), undefined, async () => { + afterRan = true; + }); + controller.abort(new Error('stop')); + await assert.rejects(canceled, /stop/); + await flushEffects(); + assert.equal(canceledRan, false); + assert.equal(afterRan, true); + gate.resolve(); + await Promise.all([active, after]); + }); + + it('does not enqueue or run a pre-aborted request', () => { + const coordinator = createFilesystemLeaseCoordinator(); + const controller = new AbortController(); + const reason = new Error('already stopped'); + controller.abort(reason); + let ran = false; + assert.throws( + () => + coordinator.withLease(request('a', 'write'), controller.signal, async () => { + ran = true; + }), + (error: unknown) => error === reason, + ); + assert.equal(ran, false); + }); + + it('does not release an active lease merely because its signal aborts', async () => { + const coordinator = createFilesystemLeaseCoordinator(); + const controller = new AbortController(); + const gate = deferred(); + let secondRan = false; + const first = coordinator.withLease( + request('a', 'write'), + controller.signal, + async () => await gate.promise, + ); + const second = coordinator.withLease(request('a', 'read'), undefined, async () => { + secondRan = true; + }); + await flushEffects(); + controller.abort(); + await flushEffects(); + assert.equal(secondRan, false); + gate.resolve(); + await Promise.all([first, second]); + assert.equal(secondRan, true); + }); + + it('admits reversed multi-key requests atomically and deduplicates exact duplicates', async () => { + const coordinator = createFilesystemLeaseCoordinator(); + const normalized = normalizeFilesystemLeaseRequests([ + request('b', 'write'), + request('a', 'write'), + request('a', 'write'), + ]); + assert.deepEqual( + normalized.map(({ key }) => key), + ['a', 'b'], + ); + const gate = deferred(); + const started: string[] = []; + const first = coordinator.withLeases(normalized, undefined, async () => { + started.push('first'); + await gate.promise; + }); + const second = coordinator.withLeases( + [request('b', 'write'), request('a', 'write')], + undefined, + async () => { + started.push('second'); + }, + ); + await flushEffects(); + assert.deepEqual(started, ['first']); + gate.resolve(); + await Promise.all([first, second]); + assert.deepEqual(started, ['first', 'second']); + }); + + it('never partially admits a multi-key waiter', async () => { + const coordinator = createFilesystemLeaseCoordinator(); + const gate = deferred(); + const started: string[] = []; + const activeA = coordinator.withLease(request('a', 'read'), undefined, async () => { + started.push('active-a'); + await gate.promise; + }); + const multi = coordinator.withLeases( + [request('a', 'write'), request('b', 'write')], + undefined, + async () => { + started.push('multi'); + }, + ); + const laterB = coordinator.withLease(request('b', 'read'), undefined, async () => { + started.push('later-b'); + }); + const independentC = coordinator.withLease(request('c', 'write'), undefined, async () => { + started.push('independent-c'); + }); + await flushEffects(); + assert.deepEqual(started, ['active-a', 'independent-c']); + gate.resolve(); + await Promise.all([activeA, multi, laterB, independentC]); + assert.deepEqual(started, ['active-a', 'independent-c', 'multi', 'later-b']); + }); +}); diff --git a/packages/runtime/src/__tests__/filesystem-lease-key.test.ts b/packages/runtime/src/__tests__/filesystem-lease-key.test.ts new file mode 100644 index 0000000000..51258d8176 --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-lease-key.test.ts @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { filesystemLeaseKeyForPlatform } from '../filesystem-lease-key.js'; +import { containsPath } from '../preparation/claims.js'; + +describe('filesystem lease keys', () => { + it('preserves POSIX canonical paths', () => { + assert.equal(filesystemLeaseKeyForPlatform('/work/A.txt', 'linux'), '/work/A.txt'); + assert.equal(filesystemLeaseKeyForPlatform('/work/A.txt', 'darwin'), '/work/A.txt'); + }); + + it('case-folds Windows paths without locale-sensitive APIs', () => { + assert.equal( + filesystemLeaseKeyForPlatform('C:\\work\\src\\a.txt', 'win32'), + filesystemLeaseKeyForPlatform('C:\\WORK\\SRC\\A.TXT', 'win32'), + ); + assert.equal(filesystemLeaseKeyForPlatform('C:\\work\\iı.txt', 'win32'), 'C:\\WORK\\II.TXT'); + }); + + it('checks separator boundaries instead of using a bare prefix', () => { + assert.equal(containsPath('/work/src', '/work/src/a.ts'), true); + assert.equal(containsPath('/work/src', '/work/src2/a.ts'), false); + assert.equal(containsPath('C:\\work\\src', 'C:\\work\\src\\a.ts'), true); + }); +}); diff --git a/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts b/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts index 728aca30f6..0153b8324a 100644 --- a/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts +++ b/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts @@ -211,16 +211,8 @@ describe('filesystem mutation unknown-outcome classification', () => { }); }); -describe('filesystem mutation T0 identity capture (queue-window closure)', () => { - // This is the red-line test for issue #2600 concern #1. The identity must be - // captured at lock acquisition (T0), BEFORE waiting for the write lock — not - // re-derived after the lock is granted (T1). To prove that, the test must - // exercise a REAL lock wait: a first mutation blocks inside the worker while - // holding the path's lock, a second mutation queues behind it, and the path - // is replaced while the second one waits. A regression that captures the - // identity at T1 (after the lock is granted) then samples the replacement's - // inode and this test fails; a T0 capture still sees the original inode. - test('a queued mutation receives the pre-replacement identity captured at lock acquisition', async () => { +describe('filesystem mutation admission identity capture', () => { + test('a queued mutation admits the current identity after the prior owner completes', async () => { const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-t0-lockwait-'))); cleanup.push(cwd); const target = join(cwd, 'file.txt'); @@ -228,26 +220,21 @@ describe('filesystem mutation T0 identity capture (queue-window closure)', () => await writeFile(target, 'original', 'utf8'); await writeFile(replacement, 'replacement-body', 'utf8'); - const original = await stat(target, { bigint: true }); - // The first mutation blocks inside the worker, holding the write lock. let releaseFirst!: () => void; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); - let calls = 0; - let queuedIdentity: FilesystemWorkerExpectedIdentity | undefined; + const calls: FilesystemWorkerExecuteInput[] = []; const gatedWorker: { execute: (input: FilesystemWorkerExecuteInput) => Promise; } = { async execute(input) { - calls += 1; - if (calls === 1) { + calls.push(input); + if (calls.length === 1) { await firstGate; // hold the path's lock until the swap has happened return { kind: 'write', ok: true, path: target, bytes: 5 }; } - // The queued (second) call: record the identity it was handed. - queuedIdentity = input.expectedIdentity; return { kind: 'write', ok: true, path: target, bytes: 6 }; }, }; @@ -260,30 +247,26 @@ describe('filesystem mutation T0 identity capture (queue-window closure)', () => }); await sleep(50); // let the first call reach the worker and hold the lock - // Second mutation: captures its identity (T0) and queues on the lock. + // Second mutation prepares only the stable claim and queues on the lock. const second = fs.execute({ operation: { kind: 'write', path: target, content: 'second' }, cwd, }); - await sleep(50); // let the second call finish its T0 capture and queue + await sleep(50); // let the second call finish claim preparation and queue // Replace the path WHILE the second mutation is still waiting for the lock. await rename(replacement, target); - // Release the first mutation; the second acquires the lock and dispatches - // with whatever identity its capture step sampled. + const replacementIdentity = await stat(target, { bigint: true }); + // Release the first mutation; the second acquires the prepared key, samples + // the current replacement identity at admission, and dispatches against it. releaseFirst(); await Promise.all([first, second]); - - assert.ok( - queuedIdentity && typeof queuedIdentity !== 'string', - 'the queued mutation should have dispatched with the captured identity', - ); - assert.equal( - (queuedIdentity as { dev: string; ino: string }).ino, - String(original.ino), - 'identity must be the inode captured at lock acquisition (before the replacement); a T1 capture would sample the replacement', - ); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1]?.expectedIdentity, { + dev: String(replacementIdentity.dev), + ino: String(replacementIdentity.ino), + }); }); test('an apply_patch mutation forwards its captured identity, not unchecked (#3484 regression)', async () => { diff --git a/packages/runtime/src/__tests__/filesystem-stable-read.test.ts b/packages/runtime/src/__tests__/filesystem-stable-read.test.ts new file mode 100644 index 0000000000..1a7f4dff8a --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-stable-read.test.ts @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { readStableTarget } from '../file-stable-read.js'; + +const cleanup: string[] = []; +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe('fd-pinned exact read primitive', () => { + test('reads text windows through the descriptor validated at admission', async () => { + const cwd = await temporaryDirectory(); + const target = join(cwd, 'file.txt'); + await writeFile(target, 'zero\none\ntwo\nthree', 'utf8'); + + assert.deepEqual( + await readStableTarget({ + path: target, + expectedIdentity: await identity(target), + offset: 1, + limit: 2, + }), + { content: 'one\ntwo' }, + ); + }); + + test('rejects a replacement inode and never returns its content', async () => { + const cwd = await temporaryDirectory(); + const target = join(cwd, 'file.txt'); + const replacement = join(cwd, 'replacement.txt'); + await writeFile(target, 'original', 'utf8'); + await writeFile(replacement, 'replacement', 'utf8'); + const admitted = await identity(target); + await rename(replacement, target); + + await assert.rejects(readStableTarget({ path: target, expectedIdentity: admitted }), { + code: 'path_changed', + }); + assert.equal(await readFile(target, 'utf8'), 'replacement'); + }); + + test('rejects a target that appeared after a missing admission observation', async () => { + const cwd = await temporaryDirectory(); + const target = join(cwd, 'file.txt'); + await writeFile(target, 'interloper', 'utf8'); + + await assert.rejects(readStableTarget({ path: target, expectedIdentity: 'missing' }), { + code: 'path_changed', + }); + }); +}); + +async function temporaryDirectory(): Promise { + const path = await realpath(await mkdtemp(join(tmpdir(), 'maka-stable-read-'))); + cleanup.push(path); + return path; +} + +async function identity(path: string): Promise<{ dev: string; ino: string }> { + const metadata = await stat(path, { bigint: true }); + return { dev: String(metadata.dev), ino: String(metadata.ino) }; +} diff --git a/packages/runtime/src/__tests__/filesystem-target-identity.test.ts b/packages/runtime/src/__tests__/filesystem-target-identity.test.ts index 94f6b9ef75..15748fe6e8 100644 --- a/packages/runtime/src/__tests__/filesystem-target-identity.test.ts +++ b/packages/runtime/src/__tests__/filesystem-target-identity.test.ts @@ -17,9 +17,9 @@ * under the License. */ -// Red-line tests for the T0 target identity CAS (issue #2600 concern #1). -// A mutation whose target was replaced while the call waited for the write -// lock must be detected and rejected, not silently written to the replacement. +// Red-line tests for the admission-time target identity CAS (issue #2600 concern #1). +// A mutation whose target is replaced after admission must be detected and +// rejected, not silently written to the replacement. // These tests exercise the worker's assertTargetUnchanged identity check // directly against a real filesystem. import assert from 'node:assert/strict'; @@ -78,7 +78,7 @@ describe('filesystem worker target identity CAS', () => { await writeFile(target, 'original', 'utf8'); await writeFile(replacement, 'replacement', 'utf8'); - // Capture the identity of the original target (T0). + // Capture the identity admitted for the original target. const identity = await captureIdentity(target); // Swap the path to a different inode while "queued" (before the worker runs). diff --git a/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts b/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts new file mode 100644 index 0000000000..b93e94a1c5 --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts @@ -0,0 +1,641 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; + +import { buildBuiltinToolComposition } from '../builtin-tools.js'; +import type { FilesystemWorkerExecuteInput } from '../filesystem-worker/client.js'; +import type { FilesystemWorkerResult } from '../filesystem-worker/protocol.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; +import { processAllOperation } from '../preparation/placeholder-authorities.js'; +import type { ResourceClaim } from '../preparation/types.js'; +import { + createProcessResourceAdmissionCoordinator, + type ProcessResourceAdmissionCoordinator, +} from '../process-resource-admission.js'; +import { settleToolCallBatch } from '../tool-call-batch.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +const cleanup: string[] = []; + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe('filesystem ToolCallBatch scenarios', () => { + // These controlled-worker cases prove admission and ordering only. Identity + // transitions require real disk effects and live in + // filesystem-admission-identity.test.ts. + test('independent batches: active all() keeps real builtin Read and Write out of the worker', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const processAdmission = createProcessResourceAdmissionCoordinator(); + const releaseAll = deferred(); + const allStarted = deferred(); + const allOperation = processAllOperation(async () => { + allStarted.resolve(); + await releaseAll.promise; + return 'all'; + }, processAdmission); + const allBatch = settleToolCallBatch( + [ + { + id: 'bash-all', + prepare: async () => allOperation, + run: async (operation) => await operation?.execute(), + }, + ], + { processAdmission }, + ); + await allStarted.promise; + const readBatch = startBatch( + cwd, + observer, + [ + { id: 'read-a', toolName: 'Read', input: { path: 'a.txt' } }, + { id: 'write-b', toolName: 'Write', input: { path: 'b.txt', content: 'B' } }, + ], + processAdmission, + ); + await Promise.resolve(); + assert.deepEqual(observer.started, []); + + releaseAll.resolve(); + await allBatch; + await observer.waitForStarted(['read:a.txt#1', 'write:b.txt#1']); + observer.releaseAll(); + await readBatch.outcomes; + }); + + test('independent batches: active real builtin Read blocks a later all()', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const processAdmission = createProcessResourceAdmissionCoordinator(); + const readBatch = startBatch( + cwd, + observer, + [{ id: 'read-a', toolName: 'Read', input: { path: 'a.txt' } }], + processAdmission, + ); + await observer.waitForStarted(['read:a.txt#1']); + let allStarted = false; + const allOperation = processAllOperation(async () => { + allStarted = true; + return 'all'; + }, processAdmission); + const allBatch = settleToolCallBatch( + [ + { + id: 'bash-all', + prepare: async () => allOperation, + run: async (operation) => await operation?.execute(), + }, + ], + { processAdmission }, + ); + await Promise.resolve(); + assert.equal(allStarted, false); + + observer.releaseAll(); + await readBatch.outcomes; + assert.deepEqual(await allBatch, [{ status: 'fulfilled', value: 'all' }]); + assert.equal(allStarted, true); + }); + + test('independent batches: Read holds a same-file Edit until the first batch settles', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const readCalls: ScenarioCall[] = [ + { id: 'read-a', toolName: 'Read', input: { path: 'a.txt' } }, + ]; + const editCalls: ScenarioCall[] = [ + { + id: 'edit-a', + toolName: 'Edit', + input: { path: 'a.txt', old_string: 'before', new_string: 'after' }, + }, + ]; + const readBatch = startBatch(cwd, observer, readCalls); + let editBatch: ReturnType | undefined; + let readOutcomes: Awaited | undefined; + let editOutcomes: Awaited['outcomes']> | undefined; + + try { + await observer.waitForStarted(['read:a.txt#1']); + editBatch = startBatch(cwd, observer, editCalls); + await Promise.resolve(); + await Promise.resolve(); + observer.assertActive(['read:a.txt#1']); + assert.deepEqual(observer.started, ['read:a.txt#1']); + + observer.release('read:a.txt#1'); + await observer.waitForStarted(['edit:a.txt#1']); + observer.assertActive(['edit:a.txt#1']); + } finally { + observer.releaseAll(); + [readOutcomes, editOutcomes] = await Promise.all([ + readBatch.outcomes, + editBatch?.outcomes ?? Promise.resolve([]), + ]); + } + + assert.equal(observer.maxActive, 1); + assertFulfilledInModelOrder(readOutcomes, readCalls); + assertFulfilledInModelOrder(editOutcomes, editCalls); + }); + + test('independent batches: Grep tree lease holds an in-tree Write', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const grepCalls: ScenarioCall[] = [ + { id: 'grep-src', toolName: 'Grep', input: { pattern: 'needle', path: 'src' } }, + ]; + const writeCalls: ScenarioCall[] = [ + { + id: 'write-src-a', + toolName: 'Write', + input: { path: 'src/a.ts', content: 'export const a = 1;' }, + }, + ]; + const grepBatch = startBatch(cwd, observer, grepCalls); + let writeBatch: ReturnType | undefined; + let grepOutcomes: Awaited | undefined; + let writeOutcomes: Awaited['outcomes']> | undefined; + + try { + await observer.waitForStarted(['grep:src#1']); + writeBatch = startBatch(cwd, observer, writeCalls); + await Promise.resolve(); + await Promise.resolve(); + observer.assertActive(['grep:src#1']); + assert.deepEqual(observer.started, ['grep:src#1']); + + observer.release('grep:src#1'); + await observer.waitForStarted(['write:src/a.ts#1']); + observer.assertActive(['write:src/a.ts#1']); + } finally { + observer.releaseAll(); + [grepOutcomes, writeOutcomes] = await Promise.all([ + grepBatch.outcomes, + writeBatch?.outcomes ?? Promise.resolve([]), + ]); + } + + assert.equal(observer.maxActive, 1); + assertFulfilledInModelOrder(grepOutcomes, grepCalls); + assertFulfilledInModelOrder(writeOutcomes, writeCalls); + }); + + test('2 calls: same-file read then write serialize', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const calls: ScenarioCall[] = [ + { id: 'read-a', toolName: 'Read', input: { path: 'a.txt' } }, + { id: 'write-a', toolName: 'Write', input: { path: 'a.txt', content: 'A' } }, + ]; + const run = startBatch(cwd, observer, calls); + + let outcomes: Awaited | undefined; + try { + await observer.waitForStarted(['read:a.txt#1']); + observer.assertActive(['read:a.txt#1']); + assert.deepEqual(observer.started, ['read:a.txt#1']); + + observer.release('read:a.txt#1'); + await observer.waitForStarted(['write:a.txt#1']); + observer.assertActive(['write:a.txt#1']); + assert.deepEqual(observer.started, ['read:a.txt#1', 'write:a.txt#1']); + } finally { + observer.releaseAll(); + outcomes = await run.outcomes; + } + + assert.equal(observer.maxActive, 1); + assertClaims(run.claims, cwd, { + 'read-a': ['filesystem:workspace|read|exact|a.txt'], + 'write-a': ['filesystem:workspace|write|exact|a.txt'], + }); + assertFulfilledInModelOrder(outcomes, calls); + }); + + test('2 calls: same-file reads start together', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const calls: ScenarioCall[] = [ + { id: 'read-a-1', toolName: 'Read', input: { path: 'a.txt' } }, + { id: 'read-a-2', toolName: 'Read', input: { path: 'a.txt' } }, + ]; + const run = startBatch(cwd, observer, calls); + + let outcomes: Awaited | undefined; + try { + await observer.waitForStarted(['read:a.txt#1', 'read:a.txt#2']); + observer.assertActive(['read:a.txt#1', 'read:a.txt#2']); + observer.assertStarted(['read:a.txt#1', 'read:a.txt#2']); + } finally { + observer.releaseAll(); + outcomes = await run.outcomes; + } + + assert.equal(observer.maxActive, 2); + assertClaims(run.claims, cwd, { + 'read-a-1': ['filesystem:workspace|read|exact|a.txt'], + 'read-a-2': ['filesystem:workspace|read|exact|a.txt'], + }); + assertFulfilledInModelOrder(outcomes, calls); + }); + + test('3 calls: queued writer prevents a later reader from bypassing it', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const calls: ScenarioCall[] = [ + { id: 'read-a-1', toolName: 'Read', input: { path: 'a.txt' } }, + { id: 'write-a', toolName: 'Write', input: { path: 'a.txt', content: 'A' } }, + { id: 'read-a-2', toolName: 'Read', input: { path: 'a.txt' } }, + ]; + const run = startBatch(cwd, observer, calls); + + let outcomes: Awaited | undefined; + try { + await observer.waitForStarted(['read:a.txt#1']); + observer.assertActive(['read:a.txt#1']); + assert.deepEqual(observer.started, ['read:a.txt#1']); + + observer.release('read:a.txt#1'); + await observer.waitForStarted(['write:a.txt#1']); + observer.assertActive(['write:a.txt#1']); + assert.deepEqual(observer.started, ['read:a.txt#1', 'write:a.txt#1']); + + observer.release('write:a.txt#1'); + await observer.waitForStarted(['read:a.txt#2']); + observer.assertActive(['read:a.txt#2']); + assert.deepEqual(observer.started, ['read:a.txt#1', 'write:a.txt#1', 'read:a.txt#2']); + } finally { + observer.releaseAll(); + outcomes = await run.outcomes; + } + + assert.equal(observer.maxActive, 1); + assertClaims(run.claims, cwd, { + 'read-a-1': ['filesystem:workspace|read|exact|a.txt'], + 'write-a': ['filesystem:workspace|write|exact|a.txt'], + 'read-a-2': ['filesystem:workspace|read|exact|a.txt'], + }); + assertFulfilledInModelOrder(outcomes, calls); + }); + + test('4 calls: tree read blocks only the in-tree writer', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const calls: ScenarioCall[] = [ + { id: 'grep-src', toolName: 'Grep', input: { pattern: 'needle', path: 'src' } }, + { + id: 'write-src-a', + toolName: 'Write', + input: { path: 'src/a.ts', content: 'export const a = 1;' }, + }, + { + id: 'write-other-b', + toolName: 'Write', + input: { path: 'other/b.ts', content: 'export const b = 2;' }, + }, + { id: 'read-src-c', toolName: 'Read', input: { path: 'src/c.ts' } }, + ]; + const run = startBatch(cwd, observer, calls); + + let outcomes: Awaited | undefined; + try { + await observer.waitForStarted(['grep:src#1', 'write:other/b.ts#1', 'read:src/c.ts#1']); + observer.assertActive(['grep:src#1', 'write:other/b.ts#1', 'read:src/c.ts#1']); + observer.assertStarted(['grep:src#1', 'write:other/b.ts#1', 'read:src/c.ts#1']); + + observer.release('grep:src#1'); + await observer.waitForStarted(['write:src/a.ts#1']); + observer.assertActive(['write:other/b.ts#1', 'read:src/c.ts#1', 'write:src/a.ts#1']); + observer.assertStarted([ + 'grep:src#1', + 'write:other/b.ts#1', + 'read:src/c.ts#1', + 'write:src/a.ts#1', + ]); + } finally { + observer.releaseAll(); + outcomes = await run.outcomes; + } + + assert.equal(observer.maxActive, 3); + assertClaims(run.claims, cwd, { + 'grep-src': ['filesystem:workspace|read|tree|src'], + 'write-src-a': ['filesystem:workspace|write|exact|src/a.ts'], + 'write-other-b': ['filesystem:workspace|write|exact|other/b.ts'], + 'read-src-c': ['filesystem:workspace|read|exact|src/c.ts'], + }); + assertFulfilledInModelOrder(outcomes, calls); + }); + + test('5 calls: tree conflict, writer fairness, and src/src2 boundary compose', async () => { + const cwd = await scenarioWorkspace(); + const observer = new ControlledFilesystemWorker(); + const calls: ScenarioCall[] = [ + { id: 'grep-src', toolName: 'Grep', input: { pattern: 'needle', path: 'src' } }, + { + id: 'write-src-a', + toolName: 'Write', + input: { path: 'src/a.ts', content: 'export const a = 1;' }, + }, + { id: 'read-src-a', toolName: 'Read', input: { path: 'src/a.ts' } }, + { + id: 'write-src2-a', + toolName: 'Write', + input: { path: 'src2/a.ts', content: 'export const sibling = true;' }, + }, + { id: 'glob-src', toolName: 'Glob', input: { pattern: '**/*.ts', cwd: 'src' } }, + ]; + const run = startBatch(cwd, observer, calls); + + let outcomes: Awaited | undefined; + try { + await observer.waitForStarted(['grep:src#1', 'write:src2/a.ts#1']); + observer.assertActive(['grep:src#1', 'write:src2/a.ts#1']); + observer.assertStarted(['grep:src#1', 'write:src2/a.ts#1']); + + observer.release('grep:src#1'); + await observer.waitForStarted(['write:src/a.ts#1']); + observer.assertActive(['write:src2/a.ts#1', 'write:src/a.ts#1']); + observer.assertStarted(['grep:src#1', 'write:src2/a.ts#1', 'write:src/a.ts#1']); + + observer.release('write:src/a.ts#1'); + await observer.waitForStarted(['read:src/a.ts#1', 'glob:src#1']); + observer.assertActive(['write:src2/a.ts#1', 'read:src/a.ts#1', 'glob:src#1']); + observer.assertStarted([ + 'grep:src#1', + 'write:src2/a.ts#1', + 'write:src/a.ts#1', + 'read:src/a.ts#1', + 'glob:src#1', + ]); + } finally { + observer.releaseAll(); + outcomes = await run.outcomes; + } + + assert.equal(observer.maxActive, 3); + assertClaims(run.claims, cwd, { + 'grep-src': ['filesystem:workspace|read|tree|src'], + 'write-src-a': ['filesystem:workspace|write|exact|src/a.ts'], + 'read-src-a': ['filesystem:workspace|read|exact|src/a.ts'], + 'write-src2-a': ['filesystem:workspace|write|exact|src2/a.ts'], + 'glob-src': ['filesystem:workspace|read|tree|src'], + }); + assertFulfilledInModelOrder(outcomes, calls); + }); +}); + +interface ScenarioCall { + readonly id: string; + readonly toolName: string; + readonly input: unknown; +} + +interface ScenarioValue { + readonly id: string; + readonly output: unknown; +} + +function startBatch( + cwd: string, + observer: ControlledFilesystemWorker, + calls: readonly ScenarioCall[], + processAdmission?: ProcessResourceAdmissionCoordinator, +): { + readonly claims: Map; + readonly outcomes: Promise[]>; +} { + const composition = buildBuiltinToolComposition({ + filesystemWorker: { execute: (input) => observer.execute(input) }, + ...(processAdmission ? { processResourceAdmissionCoordinator: processAdmission } : {}), + }); + const preparation = new ToolPreparationService(composition.authorityRegistry, processAdmission); + const tools = new Map(composition.tools.map((tool) => [tool.name, tool])); + const claims = new Map(); + const abortSignal = new AbortController().signal; + const executionBoundary = createManagedExecutionBoundary( + createWorkspaceWritePermissionProfile(), + 0, + ); + + const outcomes = settleToolCallBatch( + calls.map((call) => { + const tool = tools.get(call.toolName); + if (!tool) throw new Error(`${call.toolName} tool missing`); + const ctx: MakaToolContext = { + sessionId: 'filesystem-batch-session', + turnId: 'filesystem-batch-turn', + toolCallId: call.id, + cwd, + permissionMode: 'ask', + executionBoundary, + abortSignal, + emitOutput: () => {}, + }; + return { + id: call.id, + signal: abortSignal, + prepare: async () => { + const operation = await preparation.prepare({ tool, input: call.input, ctx }); + claims.set(call.id, operation.claims); + return operation; + }, + run: async (operation): Promise => { + const fallbackEffect = async () => await tool.impl(call.input as never, ctx); + const output = operation + ? await operation.execute(abortSignal, fallbackEffect) + : await fallbackEffect(); + return { id: call.id, output }; + }, + }; + }), + processAdmission ? { processAdmission } : {}, + ); + + return { claims, outcomes }; +} + +class ControlledFilesystemWorker { + readonly started: string[] = []; + readonly finished: string[] = []; + private readonly counts = new Map(); + private readonly gates = new Map>>(); + private readonly active = new Set(); + private autoRelease = false; + maxActive = 0; + + async execute(input: FilesystemWorkerExecuteInput): Promise { + const observedPath = relative(input.cwd, input.operation.path) || '.'; + const base = `${input.operation.kind}:${observedPath.replaceAll('\\', '/')}`; + const ordinal = (this.counts.get(base) ?? 0) + 1; + this.counts.set(base, ordinal); + const label = `${base}#${ordinal}`; + const gate = deferred(); + this.gates.set(label, gate); + this.started.push(label); + this.active.add(label); + this.maxActive = Math.max(this.maxActive, this.active.size); + if (this.autoRelease) gate.resolve(); + + await gate.promise; + this.active.delete(label); + this.finished.push(label); + return fakeWorkerResult(input); + } + + release(label: string): void { + const gate = this.gates.get(label); + if (!gate) throw new Error(`Cannot release ${label}: operation has not started`); + gate.resolve(); + } + + releaseAll(): void { + this.autoRelease = true; + for (const gate of this.gates.values()) gate.resolve(); + } + + async waitForStarted(labels: readonly string[]): Promise { + const deadline = Date.now() + 2_000; + while (!labels.every((label) => this.started.includes(label))) { + if (Date.now() >= deadline) { + throw new Error( + `Timed out waiting for [${labels.join(', ')}]; started=[${this.started.join(', ')}]`, + ); + } + await new Promise((resolve) => setImmediate(resolve)); + } + await Promise.resolve(); + } + + assertActive(expected: readonly string[]): void { + assert.deepEqual([...this.active].sort(), [...expected].sort()); + } + + assertStarted(expected: readonly string[]): void { + assert.deepEqual([...this.started].sort(), [...expected].sort()); + } +} + +function fakeWorkerResult(input: FilesystemWorkerExecuteInput): FilesystemWorkerResult { + switch (input.operation.kind) { + case 'read': + return { kind: 'read', content: `content:${input.operation.path}` }; + case 'write': + return { + kind: 'write', + ok: true, + path: input.operation.path, + bytes: Buffer.byteLength(input.operation.content), + }; + case 'apply_patch': + return { kind: 'apply_patch', ok: true, path: input.operation.path }; + case 'edit': + return { + kind: 'edit', + ok: true, + path: input.operation.path, + replacements: 1, + matchedVia: 'exact', + startLine: 1, + endLine: 1, + }; + case 'format_json': + return { + kind: 'format_json', + ok: true, + valid: true, + path: input.operation.path, + bytesBefore: 2, + bytesAfter: 3, + byteDelta: 1, + changed: true, + }; + case 'glob': + return { kind: 'glob', files: [] }; + case 'grep': + return { kind: 'grep', matches: [] }; + } +} + +function assertClaims( + actual: ReadonlyMap, + cwd: string, + expected: Readonly>, +): void { + const normalized = Object.fromEntries( + [...actual].map(([id, claims]) => [id, claims.map((claim) => claimSignature(claim, cwd))]), + ); + assert.deepEqual(normalized, expected); +} + +function claimSignature(claim: ResourceClaim, cwd: string): string { + if (claim.kind === 'all') return 'all'; + if (claim.kind !== 'keyed') return `${claim.kind}|${claim.authority}|${claim.key}`; + const relativeKey = relative(cwd, claim.key).replaceAll('\\', '/') || '.'; + const key = process.platform === 'win32' ? relativeKey.toLowerCase() : relativeKey; + return `${claim.authority}|${claim.mode}|${claim.scope ?? 'exact'}|${key}`; +} + +function assertFulfilledInModelOrder( + outcomes: PromiseSettledResult[] | undefined, + calls: readonly ScenarioCall[], +): void { + assert.ok(outcomes); + assert.deepEqual( + outcomes.map((outcome) => outcome.status), + calls.map(() => 'fulfilled'), + ); + assert.deepEqual( + outcomes.map((outcome) => (outcome.status === 'fulfilled' ? outcome.value.id : undefined)), + calls.map((call) => call.id), + ); +} + +async function scenarioWorkspace(): Promise { + const path = await mkdtemp(join(tmpdir(), 'maka-filesystem-batch-')); + cleanup.push(path); + await Promise.all([ + mkdir(join(path, 'src'), { recursive: true }), + mkdir(join(path, 'src2'), { recursive: true }), + mkdir(join(path, 'other'), { recursive: true }), + ]); + return await realpath(path); +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index 40607d023e..a36296daac 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -85,7 +85,7 @@ describe('filesystem worker client permission snapshots', () => { await writeFile(target, 'keep', 'utf8'); await symlink(target, link); // Capture the symlink entry's own identity (lstat, no follow) as the - // boundary executor does at T0; a write mutation on an existing target + // boundary executor does after lease admission; a write mutation on an existing target // without it is rejected as path_changed. const linkMeta = await lstat(link, { bigint: true }); const { client, requests } = fakeClient(); @@ -103,7 +103,7 @@ describe('filesystem worker client permission snapshots', () => { assert.equal(expectedTarget?.access, 'write'); assert.equal(expectedTarget?.scope, 'exact'); assert.equal(expectedTarget?.targetType, 'symlink'); - // The symlink entry's own identity (lstat, no follow) is captured at T0 and + // The symlink entry's own identity (lstat, no follow) is captured at admission and // forwarded; only its shape is stable, not its value. assert.equal(typeof expectedTarget?.identity, 'object'); const forwarded = expectedTarget?.identity as { dev: string; ino: string }; @@ -441,7 +441,7 @@ describe('filesystem worker Linux path context', () => { operation: { kind: 'write', path: target, content: 'new' }, cwd: workspace, mode: 'ask', - // T0 observed no target (a create), so the T0 marker is 'missing'. + // Admission observed no target (a create), so the marker is 'missing'. expectedIdentity: 'missing', }); @@ -704,15 +704,13 @@ describe('filesystem worker client dispatch classification', () => { ); }); - // The missing↔existing transitions while queued (#2600 P2-1): the client - // reconciles the T0 identity against the T1 reality the normaliser derived, - // so cooperative lock-ordered changes never surface as invalid_request. - test('drops a stale identity when the target vanished while queued (delete-then-rewrite)', async () => { + test('rejects an admitted existing identity when the target vanished before dispatch', async () => { const workspace = await temporaryDirectory('maka-client-stale-identity-'); const target = join(workspace, 'file.txt'); await writeFile(target, 'original', 'utf8'); const stale = await lstat(target, { bigint: true }); - // The cooperative delete already ran: the target is gone by T1. + // Admission observed the file, then an external actor removed it before + // the worker client could pin it. await rm(target); const requests: FilesystemWorkerRequest[] = []; @@ -751,27 +749,26 @@ describe('filesystem worker client dispatch classification', () => { }, }); - // T0 captured an identity; by T1 the target is missing. The stale identity - // must be dropped — never sent on a missing target — so the write proceeds - // as a fresh exclusive create instead of failing invalid_request. - await client.execute({ - operation: { kind: 'write', path: target, content: 'new' }, - cwd: workspace, - mode: 'ask', - expectedIdentity: { dev: String(stale.dev), ino: String(stale.ino) }, - }); - - assert.equal(requests[0]?.expectedTarget.targetType, 'missing'); - // The stale identity is never sent on a missing target; the wire's - // required identity contract reports 'missing' (nothing to compare), - // which lets the worker proceed as a fresh exclusive create. - assert.equal(requests[0]?.expectedTarget.identity, 'missing'); + await assert.rejects( + client.execute({ + operation: { kind: 'write', path: target, content: 'new' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: { dev: String(stale.dev), ino: String(stale.ino) }, + }), + (error: unknown) => { + assert.ok(error instanceof FilesystemWorkerClientError); + assert.equal(error.reason, 'path_changed'); + return true; + }, + ); + assert.equal(requests.length, 0); }); - test('rejects a write whose target was created while queued (never invalid_request)', async () => { + test('rejects a write whose target appeared after admission (never invalid_request)', async () => { const workspace = await temporaryDirectory('maka-client-created-'); const target = join(workspace, 'file.txt'); - // The target was approved as missing (no identity) and appeared by T1. + // The target was admitted as missing (no identity) and then appeared. await writeFile(target, 'external-content', 'utf8'); const sandboxManager = new SandboxManager([new MacosSeatbeltBackend()]); @@ -799,8 +796,7 @@ describe('filesystem worker client dispatch classification', () => { operation: { kind: 'write', path: target, content: 'new' }, cwd: workspace, mode: 'ask', - // T0 approved the target as missing; it appeared by T1 — the - // "created while queued" race, which must stay path_changed. + // Admission approved the target as missing; it appeared before pinning. expectedIdentity: 'missing', }), (error: unknown) => { @@ -816,7 +812,7 @@ describe('filesystem worker client dispatch classification', () => { test('lets an unchecked caller write an existing target without a CAS identity (#3484)', async () => { const workspace = await temporaryDirectory('maka-client-unchecked-'); const target = join(workspace, 'file.txt'); - // The target already exists; the caller has no T0 snapshot to compare + // The target already exists; the caller has no admitted snapshot to compare // (e.g. a verification script that owns the path itself). await writeFile(target, 'existing', 'utf8'); @@ -833,8 +829,8 @@ describe('filesystem worker client dispatch classification', () => { assert.equal(expectedTarget?.identity, 'unchecked'); }); - test('marks a T0-missing create as missing on the wire, not unchecked (#3484)', async () => { - const workspace = await temporaryDirectory('maka-client-t0missing-'); + test('marks an admitted-missing create as missing on the wire, not unchecked (#3484)', async () => { + const workspace = await temporaryDirectory('maka-client-admitted-missing-'); const target = join(workspace, 'new.txt'); const { client, requests } = fakeClient(); @@ -860,14 +856,31 @@ describe('filesystem worker client dispatch classification', () => { operation: { kind: 'read', path: target }, cwd: workspace, mode: 'ask', - // No expectedIdentity: reads never participate in CAS, and the client - // must not reject or silently omit the wire field — a plain-JavaScript - // caller that bypasses TypeScript has no way to get this wrong. + // Legacy callers may omit expectedIdentity; normal authority-backed exact + // Read calls supply one. }); assert.equal(requests[0]?.expectedTarget.identity, 'unchecked'); }); + test('an exact read preserves its admission identity on the wire', async () => { + const workspace = await temporaryDirectory('maka-client-read-identity-'); + const target = join(workspace, 'file.txt'); + await writeFile(target, 'content', 'utf8'); + const metadata = await lstat(target, { bigint: true }); + const identity = { dev: String(metadata.dev), ino: String(metadata.ino) }; + + const { client, requests } = fakeClient(); + await client.execute({ + operation: { kind: 'read', path: target }, + cwd: workspace, + mode: 'ask', + expectedIdentity: identity, + }); + + assert.deepEqual(requests[0]?.expectedTarget.identity, identity); + }); + test('a write without expectedIdentity is rejected at runtime (#3487)', async () => { const workspace = await temporaryDirectory('maka-client-write-required-'); const target = join(workspace, 'file.txt'); diff --git a/packages/runtime/src/__tests__/one-shot-operation.test.ts b/packages/runtime/src/__tests__/one-shot-operation.test.ts new file mode 100644 index 0000000000..99c7d1fc01 --- /dev/null +++ b/packages/runtime/src/__tests__/one-shot-operation.test.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + oneShotOperation, + PreparedOperationAlreadyExecutedError, +} from '../preparation/one-shot-operation.js'; + +describe('oneShotOperation', () => { + test('rejects a concurrent duplicate before it can invoke the effect', async () => { + const gate = deferred(); + let calls = 0; + const operation = oneShotOperation({ + claims: [], + execute: async () => { + calls += 1; + await gate.promise; + return 'done'; + }, + }); + + const first = operation.execute(); + await assert.rejects( + operation.execute(), + (error: unknown) => + error instanceof PreparedOperationAlreadyExecutedError && error.state === 'running', + ); + gate.resolve(); + assert.equal(await first, 'done'); + assert.equal(calls, 1); + }); + + test('remains consumed after success', async () => { + const operation = oneShotOperation({ claims: [], execute: async () => 'done' }); + + assert.equal(await operation.execute(), 'done'); + await assert.rejects(operation.execute(), PreparedOperationAlreadyExecutedError); + }); + + test('remains consumed after a synchronous throw or asynchronous rejection', async () => { + for (const execute of [ + () => { + throw new Error('sync failure'); + }, + async () => { + throw new Error('async failure'); + }, + ]) { + const operation = oneShotOperation({ claims: [], execute }); + await assert.rejects(operation.execute(), /failure/); + await assert.rejects(operation.execute(), PreparedOperationAlreadyExecutedError); + } + }); + + test('is idempotent when a composition boundary wraps it again', () => { + const operation = oneShotOperation({ claims: [], execute: async () => undefined }); + assert.strictEqual(oneShotOperation(operation), operation); + }); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/packages/runtime/src/__tests__/process-resource-admission.test.ts b/packages/runtime/src/__tests__/process-resource-admission.test.ts new file mode 100644 index 0000000000..26c2d1319d --- /dev/null +++ b/packages/runtime/src/__tests__/process-resource-admission.test.ts @@ -0,0 +1,331 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + createProcessResourceAdmissionCoordinator, + ProcessResourceAdmissionUpgradeError, + type ProcessResourceAdmissionTransition, +} from '../process-resource-admission.js'; + +describe('ProcessResourceAdmissionCoordinator', () => { + test('admits shared holders together', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const release = deferred(); + const starts: string[] = []; + const first = coordinator.withShared(undefined, async () => { + starts.push('first'); + await release.promise; + }); + const second = coordinator.withShared(undefined, async () => { + starts.push('second'); + await release.promise; + }); + + assert.deepEqual(starts, ['first', 'second']); + assert.deepEqual(coordinator.inspect(), { + queued: [], + activeShared: 2, + activeExclusive: false, + }); + release.resolve(); + await Promise.all([first, second]); + assertIdle(coordinator.inspect()); + }); + + test('serializes shared/exclusive and exclusive/exclusive in both directions', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const releaseShared = deferred(); + const releaseExclusive = deferred(); + const events: string[] = []; + const shared = coordinator.withShared(undefined, async () => { + events.push('shared'); + await releaseShared.promise; + }); + const firstExclusive = coordinator.withExclusive(undefined, async () => { + events.push('exclusive-1'); + await releaseExclusive.promise; + }); + const secondExclusive = coordinator.withExclusive(undefined, async () => { + events.push('exclusive-2'); + }); + const laterShared = coordinator.withShared(undefined, async () => { + events.push('shared-2'); + }); + + assert.deepEqual(events, ['shared']); + releaseShared.resolve(); + await shared; + assert.deepEqual(events, ['shared', 'exclusive-1']); + releaseExclusive.resolve(); + await firstExclusive; + assert.deepEqual(events, ['shared', 'exclusive-1', 'exclusive-2']); + await secondExclusive; + assert.deepEqual(events, ['shared', 'exclusive-1', 'exclusive-2', 'shared-2']); + await laterShared; + assertIdle(coordinator.inspect()); + }); + + test('does not let a later shared holder overtake a queued writer', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const releaseFirst = deferred(); + const releaseWriter = deferred(); + const events: string[] = []; + const first = coordinator.withShared(undefined, async () => { + events.push('shared-1'); + await releaseFirst.promise; + }); + const writer = coordinator.withExclusive(undefined, async () => { + events.push('exclusive'); + await releaseWriter.promise; + }); + const later = coordinator.withShared(undefined, async () => { + events.push('shared-2'); + }); + + assert.deepEqual(events, ['shared-1']); + releaseFirst.resolve(); + await first; + assert.deepEqual(events, ['shared-1', 'exclusive']); + releaseWriter.resolve(); + await writer; + assert.deepEqual(events, ['shared-1', 'exclusive', 'shared-2']); + await later; + }); + + test('removes an aborted queued writer and immediately drains later shared work', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const release = deferred(); + const controller = new AbortController(); + const events: string[] = []; + const first = coordinator.withShared(undefined, async () => { + events.push('shared-1'); + await release.promise; + }); + const writer = coordinator.withExclusive(controller.signal, async () => { + events.push('must-not-run'); + }); + const writerRejected = assert.rejects(writer, /cancelled/); + const later = coordinator.withShared(undefined, async () => { + events.push('shared-2'); + }); + + assert.deepEqual(events, ['shared-1']); + controller.abort(new Error('cancelled')); + assert.deepEqual(events, ['shared-1', 'shared-2']); + await writerRejected; + await later; + release.resolve(); + await first; + assertIdle(coordinator.inspect()); + }); + + test('rejects pre-aborted work without running its effect', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const controller = new AbortController(); + const reason = new Error('already cancelled'); + controller.abort(reason); + let ran = false; + + await assert.rejects( + coordinator.withExclusive(controller.signal, async () => { + ran = true; + }), + (error) => error === reason, + ); + assert.equal(ran, false); + assertIdle(coordinator.inspect()); + }); + + test('rejects a pre-aborted compatible reentrant call without running it', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const controller = new AbortController(); + controller.abort(new Error('nested cancelled')); + let nestedRan = false; + + await coordinator.withExclusive(undefined, async () => { + await assert.rejects( + coordinator.withShared(controller.signal, async () => { + nestedRan = true; + }), + /nested cancelled/, + ); + }); + assert.equal(nestedRan, false); + assertIdle(coordinator.inspect()); + }); + + test('does not release an active holder merely because its signal aborts', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const controller = new AbortController(); + const release = deferred(); + let laterStarted = false; + const active = coordinator.withShared(controller.signal, async () => { + await release.promise; + }); + const later = coordinator.withExclusive(undefined, async () => { + laterStarted = true; + }); + + controller.abort(new Error('active cancellation')); + assert.equal(laterStarted, false); + release.resolve(); + await active; + assert.equal(laterStarted, true); + await later; + }); + + test('releases and drains before an effect rejection is observable', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const release = deferred(); + const events: string[] = []; + const first = coordinator.withExclusive(undefined, async () => { + events.push('first:start'); + await release.promise; + events.push('first:end'); + throw new Error('expected failure'); + }); + const second = coordinator.withShared(undefined, async () => { + events.push('second:start'); + }); + + release.resolve(); + await assert.rejects(first, /expected failure/); + assert.deepEqual(events, ['first:start', 'first:end', 'second:start']); + await second; + assertIdle(coordinator.inspect()); + }); + + test('reuses compatible owners and rejects shared-to-exclusive upgrades', async () => { + const transitions: ProcessResourceAdmissionTransition[] = []; + const coordinator = createProcessResourceAdmissionCoordinator({ + onTransition: (transition) => transitions.push(transition), + }); + const events: string[] = []; + + await coordinator.withExclusive(undefined, async () => { + events.push('outer-exclusive'); + await coordinator.withShared(undefined, async () => { + events.push('nested-shared'); + }); + await coordinator.withExclusive(undefined, async () => { + events.push('nested-exclusive'); + }); + }); + await coordinator.withShared(undefined, async () => { + await assert.rejects( + coordinator.withExclusive(undefined, async () => undefined), + (error) => + error instanceof ProcessResourceAdmissionUpgradeError && + error.code === 'process_admission_upgrade_not_allowed', + ); + }); + + assert.deepEqual(events, ['outer-exclusive', 'nested-shared', 'nested-exclusive']); + assert.equal(transitions.filter((transition) => transition.reused_owner === true).length, 2); + assertIdle(coordinator.inspect()); + }); + + test('holds the root admission until fire-and-forget nested references settle', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const nestedRelease = deferred(); + const nestedStarted = deferred(); + let nested!: Promise; + let outerSettled = false; + let laterStarted = false; + const outer = coordinator.withExclusive(undefined, async () => { + nested = coordinator.withShared(undefined, async () => { + nestedStarted.resolve(); + await nestedRelease.promise; + }); + return 'outer-result'; + }); + void outer.then(() => { + outerSettled = true; + }); + await nestedStarted.promise; + const later = coordinator.withShared(undefined, async () => { + laterStarted = true; + }); + await flushMicrotasks(); + + assert.equal(outerSettled, false); + assert.equal(laterStarted, false); + nestedRelease.resolve(); + await nested; + assert.equal(await outer, 'outer-result'); + assert.equal(laterStarted, true); + await later; + assertIdle(coordinator.inspect()); + }); + + test('does not reuse an inactive owner from a stale async context', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const trigger = deferred(); + const lateStarted = deferred(); + let late!: Promise; + await coordinator.withExclusive(undefined, async () => { + void trigger.promise.then(() => { + late = coordinator.withShared(undefined, async () => { + lateStarted.resolve(); + }); + }); + }); + + trigger.resolve(); + await lateStarted.promise; + await late; + assertIdle(coordinator.inspect()); + }); + + test('treats transition observer failures as diagnostic-only', async () => { + const coordinator = createProcessResourceAdmissionCoordinator({ + onTransition: () => { + throw new Error('trace unavailable'); + }, + }); + + assert.equal(await coordinator.withExclusive(undefined, async () => 'ok'), 'ok'); + assertIdle(coordinator.inspect()); + }); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function assertIdle(snapshot: { + readonly queued: readonly unknown[]; + readonly activeShared: number; + readonly activeExclusive: boolean; +}): void { + assert.deepEqual(snapshot, { queued: [], activeShared: 0, activeExclusive: false }); +} diff --git a/packages/runtime/src/__tests__/tool-access.test.ts b/packages/runtime/src/__tests__/tool-access.test.ts new file mode 100644 index 0000000000..ec4cb48ec2 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-access.test.ts @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + normalizeToolAccesses, + normalizeToolFilePath, + ToolAccesses, + toolAccessesConflict, +} from '../tool-access.js'; + +const POSIX = { cwd: '/repo', platform: 'linux' as const }; + +describe('ToolAccesses conflict model', () => { + test('allows overlapping readers and conflicts every overlapping writer', () => { + const read = ToolAccesses.readFile('/repo/a.ts', POSIX); + const search = ToolAccesses.searchTree('/repo', POSIX); + const write = ToolAccesses.writeFile('/repo/a.ts', POSIX); + const readWrite = ToolAccesses.readWriteFile('/repo/a.ts', POSIX); + + assert.equal(toolAccessesConflict(read, read), false); + assert.equal(toolAccessesConflict(read, search), false); + assert.equal(toolAccessesConflict(read, write), true); + assert.equal(toolAccessesConflict(write, read), true); + assert.equal(toolAccessesConflict(write, write), true); + assert.equal(toolAccessesConflict(readWrite, read), true); + }); + + test('allows writes to different files', () => { + assert.equal( + toolAccessesConflict( + ToolAccesses.writeFile('/repo/a.ts', POSIX), + ToolAccesses.writeFile('/repo/b.ts', POSIX), + ), + false, + ); + }); + + test('compares recursive ranges by path segment instead of string prefix', () => { + const tree = ToolAccesses.writeTree('/repo/src', POSIX); + assert.equal( + toolAccessesConflict(tree, ToolAccesses.readFile('/repo/src/nested/a.ts', POSIX)), + true, + ); + assert.equal( + toolAccessesConflict(tree, ToolAccesses.readFile('/repo/src2/a.ts', POSIX)), + false, + ); + }); + + test('blocks a multi-access task when any resource conflicts', () => { + const copy = [ + ...ToolAccesses.readFile('/repo/source.ts', POSIX), + ...ToolAccesses.writeFile('/repo/target.ts', POSIX), + ]; + assert.equal(toolAccessesConflict(copy, ToolAccesses.readFile('/repo/target.ts', POSIX)), true); + assert.equal(toolAccessesConflict(copy, ToolAccesses.readFile('/repo/other.ts', POSIX)), false); + }); + + test('treats all as conflicting with non-empty accesses but not none', () => { + assert.equal( + toolAccessesConflict(ToolAccesses.all(), ToolAccesses.readFile('/repo/a', POSIX)), + true, + ); + assert.equal(toolAccessesConflict(ToolAccesses.all(), ToolAccesses.all()), true); + assert.equal(toolAccessesConflict(ToolAccesses.all(), ToolAccesses.none()), false); + assert.equal( + toolAccessesConflict(ToolAccesses.none(), ToolAccesses.writeFile('/repo/a', POSIX)), + false, + ); + }); + + test('uses namespaced logical keys with read/write semantics', () => { + const read = ToolAccesses.readKey('session:s1:todo'); + const write = ToolAccesses.writeKey('session:s1:todo'); + assert.equal(toolAccessesConflict(read, read), false); + assert.equal(toolAccessesConflict(read, write), true); + assert.equal(toolAccessesConflict(write, ToolAccesses.writeKey('session:s2:todo')), false); + }); +}); + +describe('ToolAccesses normalization', () => { + test('resolves dot segments and normalizes Windows separators and case', () => { + assert.equal( + normalizeToolFilePath('src\\..\\SRC\\A.ts', { + cwd: 'C:\\Repo', + platform: 'win32', + }), + 'c:/repo/src/a.ts', + ); + }); + + test('normalizes raw declarations at the batch boundary', () => { + assert.deepEqual( + normalizeToolAccesses([{ kind: 'file', operation: 'write', path: './src/../a.ts' }], POSIX), + [{ kind: 'file', operation: 'write', path: '/repo/a.ts' }], + ); + }); +}); diff --git a/packages/runtime/src/__tests__/tool-authority-kimi-semantics-batch.test.ts b/packages/runtime/src/__tests__/tool-authority-kimi-semantics-batch.test.ts new file mode 100644 index 0000000000..0170f9f4f9 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-authority-kimi-semantics-batch.test.ts @@ -0,0 +1,392 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { claimsConflict } from '../preparation/claims.js'; +import { defaultToolAuthorityRegistrations } from '../preparation/default-tool-authorities.js'; +import { noneOperation } from '../preparation/placeholder-authorities.js'; +import { ToolAuthorityRegistry } from '../preparation/tool-authority-registry.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; +import type { + AuthorityContext, + PreparedOperation, + ResourceAuthority, + ResourceClaim, +} from '../preparation/types.js'; +import { settleToolCallBatch, type ToolCallBatchEntry } from '../tool-call-batch.js'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; + +const FILE = 'filesystem:test'; +const read = (key: string): ResourceClaim[] => [ + { kind: 'keyed', authority: FILE, key, mode: 'read' }, +]; +const write = (key: string): ResourceClaim[] => [ + { kind: 'keyed', authority: FILE, key, mode: 'write' }, +]; +const tree = (key: string): ResourceClaim[] => [ + { kind: 'keyed', authority: FILE, key, mode: 'read', scope: 'tree' }, +]; +const all = (): ResourceClaim[] => [{ kind: 'all' }]; +const none = (): ResourceClaim[] => []; + +describe('Kimi claim predicate', () => { + test('C01: all conflicts with every non-empty modelled claim', () => { + assert.equal(claimsConflict(all(), read('a')), true); + assert.equal(claimsConflict(all(), write('a')), true); + assert.equal(claimsConflict(all(), tree('src')), true); + assert.equal( + claimsConflict(all(), [{ kind: 'capacity', authority: 'web', key: 'p', permits: 1 }]), + true, + ); + assert.equal(claimsConflict(all(), [{ kind: 'coarse', authority: 'shell', key: 'w' }]), true); + assert.equal(claimsConflict(all(), all()), true); + }); + + test('C02: all and none do not conflict in either direction', () => { + assert.equal(claimsConflict(all(), none()), false); + assert.equal(claimsConflict(none(), all()), false); + assert.equal(claimsConflict(none(), none()), false); + }); + + test('C03: keyed read/write and tree boundaries retain their semantics', () => { + assert.equal(claimsConflict(read('a'), read('a')), false); + assert.equal(claimsConflict(read('a'), write('a')), true); + assert.equal(claimsConflict(write('a'), write('b')), false); + assert.equal(claimsConflict(tree('src'), write('src/a')), true); + assert.equal(claimsConflict(tree('src'), write('src2/a')), false); + }); +}); + +describe('Kimi ToolCallBatch semantics', () => { + test('B01: all blocks a later filesystem claim', async () => { + const h = harness([call('update_agent_graph', 'all'), call('Read', 'read', 'a')]); + const batch = h.run(); + await h.waitStarted('all'); + await h.expectStarted('all'); + h.finish('all'); + await h.waitStarted('read'); + h.finish('read'); + assertSlots(await batch, ['all', 'read']); + }); + + test('B02: all does not block none', async () => { + const h = harness([call('update_agent_graph', 'all'), call('WebSearch', 'web')]); + const batch = h.run(); + await Promise.all([h.waitStarted('all'), h.waitStarted('web')]); + await h.expectStarted('all', 'web'); + h.finish('web'); + h.finish('all'); + assertSlots(await batch, ['all', 'web']); + }); + + test('B03: queued all prevents a later independent reader from bypassing', async () => { + const h = harness([ + call('Read', 'read-a', 'a'), + call('update_agent_graph', 'all'), + call('Read', 'read-b', 'b'), + ]); + const batch = h.run(); + await h.waitStarted('read-a'); + await h.expectStarted('read-a'); + h.finish('read-a'); + await h.waitStarted('all'); + await h.expectStarted('read-a', 'all'); + h.finish('all'); + await h.waitStarted('read-b'); + h.finish('read-b'); + assertSlots(await batch, ['read-a', 'all', 'read-b']); + }); + + test('B04: none bypasses queued all and remains concurrent with it', async () => { + const h = harness([ + call('Read', 'read', 'a'), + call('update_agent_graph', 'all'), + call('WebSearch', 'web'), + ]); + const batch = h.run(); + await Promise.all([h.waitStarted('read'), h.waitStarted('web')]); + await h.expectStarted('read', 'web'); + h.finish('read'); + await h.waitStarted('all'); + await h.expectActive('all', 'web'); + h.finish('all'); + h.finish('web'); + assertSlots(await batch, ['read', 'all', 'web']); + }); + + test('B05: active all blocks only non-empty claims', async () => { + const h = harness([ + call('WebSearch', 'web'), + call('update_agent_graph', 'all'), + call('Write', 'write', 'a'), + call('agent_output', 'agent-output'), + ]); + const batch = h.run(); + await Promise.all([h.waitStarted('web'), h.waitStarted('all'), h.waitStarted('agent-output')]); + await h.expectActive('web', 'all', 'agent-output'); + h.finish('all'); + await h.waitStarted('write'); + h.finish('web'); + h.finish('agent-output'); + h.finish('write'); + assertSlots(await batch, ['web', 'all', 'write', 'agent-output']); + }); + + test('B06: registry miss defaults to all and participates in fairness', async () => { + const h = harness([ + call('Read', 'read', 'a'), + call('UnknownMcpTool', 'unknown'), + call('WebFetch', 'fetch'), + call('Write', 'write', 'b'), + ]); + const batch = h.run(); + await Promise.all([h.waitStarted('read'), h.waitStarted('fetch')]); + await h.expectStarted('read', 'fetch'); + h.finish('read'); + await h.waitStarted('unknown'); + await h.expectActive('unknown', 'fetch'); + h.finish('unknown'); + await h.waitStarted('write'); + h.finish('fetch'); + h.finish('write'); + assertSlots(await batch, ['read', 'unknown', 'fetch', 'write']); + }); + + test('B07: keyed writer fairness composes with an all barrier', async () => { + const h = harness([ + call('Read', 'read-a', 'a'), + call('Write', 'write-a', 'a'), + call('update_agent_graph', 'all'), + call('WebSearch', 'web'), + call('Read', 'read-b', 'b'), + ]); + const batch = h.run(); + await Promise.all([h.waitStarted('read-a'), h.waitStarted('web')]); + await h.expectStarted('read-a', 'web'); + h.finish('read-a'); + await h.waitStarted('write-a'); + h.finish('write-a'); + await h.waitStarted('all'); + h.finish('all'); + await h.waitStarted('read-b'); + h.finish('web'); + h.finish('read-b'); + assertSlots(await batch, ['read-a', 'write-a', 'all', 'web', 'read-b']); + }); + + test('B08: all claims serialize with each other while none passes through', async () => { + const h = harness([ + call('Bash', 'bash'), + call('update_agent_graph', 'all'), + call('WebSearch', 'web'), + ]); + const batch = h.run(); + await Promise.all([h.waitStarted('bash'), h.waitStarted('web')]); + await h.expectStarted('bash', 'web'); + h.finish('bash'); + await h.waitStarted('all'); + await h.expectActive('all', 'web'); + h.finish('all'); + h.finish('web'); + assertSlots(await batch, ['bash', 'all', 'web']); + }); + + test('B09: synthetic none creates no effect and no extra blocking', async () => { + const h = harness([ + call('Read', 'read', 'a'), + synthetic('synthetic'), + call('Write', 'write', 'a'), + ]); + const batch = h.run(); + await h.waitStarted('read'); + await h.expectStarted('read'); + h.finish('read'); + await h.waitStarted('write'); + h.finish('write'); + assertSlots(await batch, ['read', 'synthetic', 'write']); + assert.equal(h.starts.includes('synthetic'), false); + }); + + test('B10: prepare rejection runs the real fallback effect under all claims once', async () => { + const h = harness([ + call('Read', 'read-a', 'a'), + broken('broken'), + call('WebFetch', 'fetch'), + call('Read', 'read-b', 'b'), + ]); + const batch = h.run(); + await Promise.all([h.waitStarted('read-a'), h.waitStarted('fetch')]); + await h.expectStarted('read-a', 'fetch'); + h.finish('read-a'); + await h.waitStarted('broken'); + await h.expectActive('broken', 'fetch'); + h.finish('broken'); + await h.waitStarted('read-b'); + h.finish('fetch'); + h.finish('read-b'); + assertSlots(await batch, ['read-a', 'broken', 'fetch', 'read-b']); + assert.equal(h.starts.filter((id) => id === 'broken').length, 1); + }); + + test('B11: completion order never changes provider-order result slots', async () => { + const h = harness([ + call('WebSearch', 'a'), + call('WebFetch', 'b'), + call('agent_list', 'c'), + call('agent_output', 'd'), + ]); + const batch = h.run(); + await Promise.all(['a', 'b', 'c', 'd'].map((id) => h.waitStarted(id))); + h.finish('d'); + await flushMicrotasks(); + h.finish('b'); + await flushMicrotasks(); + h.finish('a'); + await flushMicrotasks(); + h.finish('c'); + assertSlots(await batch, ['a', 'b', 'c', 'd']); + }); +}); + +type CallSpec = + | { readonly kind: 'real'; readonly toolName: string; readonly id: string; readonly key?: string } + | { readonly kind: 'synthetic'; readonly id: string } + | { readonly kind: 'broken'; readonly id: string }; + +function call(toolName: string, id: string, key?: string): CallSpec { + return { kind: 'real', toolName, id, ...(key ? { key } : {}) }; +} + +function synthetic(id: string): CallSpec { + return { kind: 'synthetic', id }; +} + +function broken(id: string): CallSpec { + return { kind: 'broken', id }; +} + +function harness(specs: readonly CallSpec[]) { + const starts: string[] = []; + const active = new Set(); + const startSignals = new Map(specs.map((spec) => [spec.id, deferred()])); + const finishSignals = new Map(specs.map((spec) => [spec.id, deferred()])); + const exactAuthority = (mode: 'read' | 'write'): ResourceAuthority => ({ + async prepare(input, context: AuthorityContext) { + const key = (input as { key?: string }).key ?? 'default'; + return { + claims: [{ kind: 'keyed', authority: FILE, key, mode }], + execute: (signal) => context.effect?.(signal) ?? Promise.resolve(), + }; + }, + }); + const registry = new ToolAuthorityRegistry([ + ['Read', exactAuthority('read')], + ['Write', exactAuthority('write')], + ['BrokenPreparedTool', { prepare: async () => Promise.reject(new Error('broken prepare')) }], + ]).withRegistrations(defaultToolAuthorityRegistrations()); + const service = new ToolPreparationService(registry); + const context = (id: string): MakaToolContext => ({ + sessionId: 'session', + turnId: 'turn', + cwd: process.cwd(), + permissionMode: 'ask', + toolCallId: id, + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }); + const toolFor = (name: string, id: string): MakaTool => ({ + name, + description: 'Kimi semantics test tool', + parameters: undefined, + impl: async () => { + starts.push(id); + active.add(id); + startSignals.get(id)!.resolve(); + await finishSignals.get(id)!.promise; + active.delete(id); + return id; + }, + }); + const entries = specs.map>((spec) => { + if (spec.kind === 'synthetic') { + return { + id: spec.id, + prepare: async () => noneOperation(), + run: async (operation) => { + await operation?.execute(); + return spec.id; + }, + }; + } + const toolName = spec.kind === 'broken' ? 'BrokenPreparedTool' : spec.toolName; + const tool = toolFor(toolName, spec.id); + const ctx = context(spec.id); + const input = spec.kind === 'real' ? { key: spec.key } : {}; + return { + id: spec.id, + prepare: () => service.prepare({ tool, input, ctx }), + run: async (operation) => { + if (operation) return (await operation.execute()) as string; + return (await tool.impl(input as never, ctx)) as string; + }, + }; + }); + + return { + starts, + run: () => settleToolCallBatch(entries), + waitStarted: (id: string) => startSignals.get(id)!.promise, + finish(id: string) { + finishSignals.get(id)!.resolve(); + }, + async expectStarted(...ids: string[]) { + await flushMicrotasks(); + assert.deepEqual(new Set(starts), new Set(ids)); + }, + async expectActive(...ids: string[]) { + await flushMicrotasks(); + assert.deepEqual(active, new Set(ids)); + }, + }; +} + +function assertSlots(outcomes: readonly PromiseSettledResult[], ids: readonly string[]) { + assert.deepEqual( + outcomes.map((outcome) => + outcome.status === 'fulfilled' ? outcome.value : `rejected:${String(outcome.reason)}`, + ), + ids, + ); +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/packages/runtime/src/__tests__/tool-call-batch.test.ts b/packages/runtime/src/__tests__/tool-call-batch.test.ts new file mode 100644 index 0000000000..333edb302f --- /dev/null +++ b/packages/runtime/src/__tests__/tool-call-batch.test.ts @@ -0,0 +1,388 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { settleToolCallBatch, type ToolCallBatchEntry } from '../tool-call-batch.js'; +import type { PreparedOperation, ResourceClaim } from '../preparation/types.js'; +import { noneOperation, processAllOperation } from '../preparation/placeholder-authorities.js'; +import { createProcessResourceAdmissionCoordinator } from '../process-resource-admission.js'; + +const FILE = 'filesystem:workspace'; +const write = (key: string): ResourceClaim[] => [ + { kind: 'keyed', authority: FILE, key, mode: 'write' }, +]; +const none = (): ResourceClaim[] => []; + +function prepared(claims: readonly ResourceClaim[]): PreparedOperation { + return { claims, execute: () => Promise.resolve(undefined) }; +} + +function entry( + id: string, + prepare: () => Promise>, + run: () => Promise | Result, + signal?: AbortSignal, +): ToolCallBatchEntry { + return { id, ...(signal ? { signal } : {}), prepare, run }; +} + +describe('settleToolCallBatch', () => { + test('waits for every prepare before submitting tasks in model order', async () => { + const preparation = deferred(); + const first = deferred(); + const second = deferred(); + const bothStarted = deferred(); + const starts: string[] = []; + const batch = settleToolCallBatch([ + entry( + 'first', + async () => { + await preparation.promise; + return prepared(write('/repo/a')); + }, + () => { + starts.push('first'); + return first.promise; + }, + ), + entry( + 'second', + async () => { + await preparation.promise; + return prepared(write('/repo/b')); + }, + () => { + starts.push('second'); + bothStarted.resolve(); + return second.promise; + }, + ), + ]); + + await flushMicrotasks(); + assert.deepEqual(starts, []); + preparation.resolve(); + await bothStarted.promise; + assert.deepEqual(starts, ['first', 'second']); + second.resolve('B'); + first.resolve('A'); + assert.deepEqual(await batch, [ + { status: 'fulfilled', value: 'A' }, + { status: 'fulfilled', value: 'B' }, + ]); + }); + + test('keeps result slots ordered when tasks complete B, C, A and one fails', async () => { + const gates = [deferred(), deferred(), deferred()]; + const batch = settleToolCallBatch( + gates.map((gate, index) => + entry( + String(index), + async () => prepared(none()), + () => gate.promise, + ), + ), + ); + + gates[1]!.resolve('B'); + gates[2]!.reject(new Error('C failed')); + gates[0]!.resolve('A'); + const outcomes = await batch; + assert.equal(outcomes[0]?.status, 'fulfilled'); + assert.equal(outcomes[0]?.status === 'fulfilled' ? outcomes[0].value : undefined, 'A'); + assert.equal(outcomes[1]?.status, 'fulfilled'); + assert.equal(outcomes[1]?.status === 'fulfilled' ? outcomes[1].value : undefined, 'B'); + assert.equal(outcomes[2]?.status, 'rejected'); + assert.match( + String(outcomes[2]?.status === 'rejected' ? outcomes[2].reason : undefined), + /C failed/, + ); + }); + + test('uses all claims when prepare throws but the real fallback effect still runs', async () => { + const reader = deferred(); + const broken = deferred(); + const readerStarted = deferred(); + const brokenStarted = deferred(); + const starts: string[] = []; + const batch = settleToolCallBatch([ + entry( + 'reader', + async () => prepared([{ kind: 'keyed', authority: FILE, key: '/repo/a', mode: 'read' }]), + () => { + starts.push('reader'); + readerStarted.resolve(); + return reader.promise; + }, + ), + entry( + 'broken', + async () => { + throw new Error('bad declaration'); + }, + () => { + starts.push('broken'); + brokenStarted.resolve(); + return broken.promise; + }, + ), + entry( + 'writer', + async () => prepared(write('/repo/b')), + () => { + starts.push('writer'); + return undefined; + }, + ), + ]); + + await readerStarted.promise; + assert.deepEqual(starts, ['reader']); + reader.resolve(); + await brokenStarted.promise; + assert.deepEqual(starts, ['reader', 'broken']); + broken.resolve(); + await batch; + assert.deepEqual(starts, ['reader', 'broken', 'writer']); + }); + + test('does not start a task cancelled before it is submitted', async () => { + const preparation = deferred(); + const controller = new AbortController(); + let starts = 0; + const batch = settleToolCallBatch([ + entry( + 'preparing', + async () => { + await preparation.promise; + return prepared(none()); + }, + () => 'ok', + ), + entry( + 'cancelled', + async () => { + await preparation.promise; + return prepared(none()); + }, + () => { + starts += 1; + return 'should not run'; + }, + controller.signal, + ), + ]); + + // Abort synchronously (before any prepare settles) so the cancelled entry + // is submitted with an already-aborted signal and rejected by the + // Scheduler before it runs. + controller.abort(new Error('turn cancelled')); + preparation.resolve(); + const outcomes = await batch; + assert.equal(starts, 0); + assert.equal(outcomes[0]?.status, 'fulfilled'); + assert.equal(outcomes[1]?.status, 'rejected'); + }); + + test('lets none() start in the same batch while all() is active', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const releaseAll = deferred(); + const allStarted = deferred(); + const noneStarted = deferred(); + const allOperation = processAllOperation(async () => { + allStarted.resolve(); + await releaseAll.promise; + return 'all'; + }, coordinator); + const bypassOperation = noneOperation(async () => { + noneStarted.resolve(); + return 'none'; + }); + + const batch = settleToolCallBatch( + [operationEntry('all', allOperation), operationEntry('none', bypassOperation)], + { processAdmission: coordinator }, + ); + await allStarted.promise; + await noneStarted.promise; + assert.equal(coordinator.inspect().activeExclusive, true); + releaseAll.resolve(); + assert.deepEqual(await batch, [ + { status: 'fulfilled', value: 'all' }, + { status: 'fulfilled', value: 'none' }, + ]); + }); + + test('lets none() bypass active and queued all() work across independent batches', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const releaseShared = deferred(); + const releaseAll = deferred(); + const allStarted = deferred(); + let noneRuns = 0; + const shared = coordinator.withShared(undefined, async () => { + await releaseShared.promise; + }); + const allBatch = settleToolCallBatch( + [ + operationEntry( + 'all', + processAllOperation(async () => { + allStarted.resolve(); + await releaseAll.promise; + return 'all'; + }, coordinator), + ), + ], + { processAdmission: coordinator }, + ); + await flushMicrotasks(); + assert.equal(coordinator.inspect().queued[0]?.mode, 'exclusive'); + + const queuedBypass = await settleToolCallBatch( + [ + operationEntry( + 'none-queued', + noneOperation(async () => { + noneRuns += 1; + return 'none-queued'; + }), + ), + ], + { processAdmission: coordinator }, + ); + assert.deepEqual(queuedBypass, [{ status: 'fulfilled', value: 'none-queued' }]); + releaseShared.resolve(); + await shared; + await allStarted.promise; + + const activeBypass = await settleToolCallBatch( + [ + operationEntry( + 'none-active', + noneOperation(async () => { + noneRuns += 1; + return 'none-active'; + }), + ), + ], + { processAdmission: coordinator }, + ); + assert.deepEqual(activeBypass, [{ status: 'fulfilled', value: 'none-active' }]); + assert.equal(noneRuns, 2); + releaseAll.resolve(); + await allBatch; + }); + + test('does not make all() wait for a long-running none()', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const releaseNone = deferred(); + const noneStarted = deferred(); + const allStarted = deferred(); + const noneBatch = settleToolCallBatch( + [ + operationEntry( + 'none', + noneOperation(async () => { + noneStarted.resolve(); + await releaseNone.promise; + return 'none'; + }), + ), + ], + { processAdmission: coordinator }, + ); + await noneStarted.promise; + const allBatch = settleToolCallBatch( + [ + operationEntry( + 'all', + processAllOperation(async () => { + allStarted.resolve(); + return 'all'; + }, coordinator), + ), + ], + { processAdmission: coordinator }, + ); + + await allStarted.promise; + assert.deepEqual(await allBatch, [{ status: 'fulfilled', value: 'all' }]); + releaseNone.resolve(); + await noneBatch; + }); + + test('runs a preparation-failure fallback under real exclusive admission', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const releaseShared = deferred(); + let fallbackStarted = false; + const shared = coordinator.withShared(undefined, async () => { + await releaseShared.promise; + }); + const fallbackBatch = settleToolCallBatch( + [ + entry( + 'fallback', + async () => { + throw new Error('prepare failed'); + }, + async () => { + fallbackStarted = true; + return 'fallback'; + }, + ), + ], + { processAdmission: coordinator }, + ); + await flushMicrotasks(); + assert.equal(fallbackStarted, false); + assert.equal(coordinator.inspect().queued[0]?.mode, 'exclusive'); + + releaseShared.resolve(); + await shared; + assert.deepEqual(await fallbackBatch, [{ status: 'fulfilled', value: 'fallback' }]); + assert.equal(fallbackStarted, true); + }); +}); + +function operationEntry( + id: string, + operation: PreparedOperation, +): ToolCallBatchEntry { + return { + id, + prepare: async () => operation, + run: async (candidate) => (await candidate?.execute()) as Result, + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/packages/runtime/src/__tests__/tool-preparation-service.test.ts b/packages/runtime/src/__tests__/tool-preparation-service.test.ts new file mode 100644 index 0000000000..333723eb41 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-preparation-service.test.ts @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { z } from 'zod'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; +import { ToolAuthorityRegistry } from '../preparation/tool-authority-registry.js'; +import { ToolPreparationService } from '../preparation/tool-preparation-service.js'; +import { createProcessResourceAdmissionCoordinator } from '../process-resource-admission.js'; + +describe('ToolPreparationService (single dispatch entry)', () => { + let cwd: string; + + before(async () => { + cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-preparation-'))); + }); + + after(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + const context = (): MakaToolContext => ({ + sessionId: 'session-1', + turnId: 'turn-1', + cwd, + permissionMode: 'ask', + toolCallId: 'tool-call-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }); + + test('validates, canonicalises and dispatches through the authority registry', async () => { + const seen: Array<{ args: unknown; cwd: string }> = []; + const tool: MakaTool = { + name: 'Write', + description: 'test', + parameters: z.object({ path: z.string(), content: z.string() }), + impl: async () => ({ ok: true }), + }; + + const service = new ToolPreparationService( + new ToolAuthorityRegistry([ + [ + 'Write', + { + prepare: async (args, ctx) => { + seen.push({ args, cwd: ctx.cwd }); + return { + claims: [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: '/repo/a.ts', + mode: 'write', + }, + ], + execute: async () => ({ ok: true }), + }; + }, + }, + ], + ]), + ); + const operation = await service.prepare({ + tool, + input: { path: 'a.ts', content: 'x' }, + ctx: context(), + }); + + assert.equal(operation.claims.length, 1); + assert.equal(seen.length, 1); + // The canonical cwd is the realpath'd one and the input snapshot is frozen. + assert.equal(seen[0]?.cwd, cwd); + assert.ok(Object.isFrozen(seen[0]!.args)); + }); + + test('schema-invalid args produce a no-claim operation (execute rejects)', async () => { + const tool: MakaTool = { + name: 'Write', + description: 'test', + parameters: z.object({ path: z.string(), content: z.string() }), + impl: async () => ({}), + }; + + const service = new ToolPreparationService( + new ToolAuthorityRegistry([ + [ + 'Write', + { + prepare: async () => { + throw new Error('must not be dispatched'); + }, + }, + ], + ]), + ); + const operation = await service.prepare({ tool, input: { path: 'a.ts' }, ctx: context() }); + assert.deepEqual(operation.claims, []); + await assert.rejects(operation.execute(), /could not be prepared/); + }); + + test('a real tool with no registered authority falls back to all()', async () => { + let ran = 0; + const tool: MakaTool = { + name: 'Untracked', + description: 'test', + parameters: z.object({ command: z.string() }), + impl: async () => { + ran += 1; + return { done: true }; + }, + }; + + const service = new ToolPreparationService(new ToolAuthorityRegistry()); + const operation = await service.prepare({ + tool, + input: { command: 'echo hi' }, + ctx: context(), + }); + assert.deepEqual(operation.claims, [{ kind: 'all' }]); + await operation.execute(); + assert.equal(ran, 1); + await assert.rejects(operation.execute(), /already been executed/); + assert.equal(ran, 1); + }); + + test('a registry miss holds real process-exclusive admission', async () => { + const coordinator = createProcessResourceAdmissionCoordinator(); + const releaseShared = deferred(); + const shared = coordinator.withShared(undefined, async () => { + await releaseShared.promise; + }); + let ran = false; + const service = new ToolPreparationService(new ToolAuthorityRegistry(), coordinator); + const operation = await service.prepare({ + tool: { + name: 'DynamicTool', + description: 'test', + parameters: z.object({}), + impl: async () => { + ran = true; + }, + }, + input: {}, + ctx: context(), + }); + const execution = operation.execute(); + await Promise.resolve(); + assert.equal(ran, false); + assert.equal(coordinator.inspect().queued[0]?.mode, 'exclusive'); + + releaseShared.resolve(); + await shared; + await execution; + assert.equal(ran, true); + }); + + test('canonicalising does not freeze the live AbortSignal', async () => { + const controller = new AbortController(); + const tool: MakaTool = { + name: 'Signal', + description: 'test', + parameters: z.object({ value: z.string() }), + impl: async () => undefined, + }; + + const service = new ToolPreparationService( + new ToolAuthorityRegistry([ + ['Signal', { prepare: async () => ({ claims: [], execute: async () => undefined }) }], + ]), + ); + await service.prepare({ + tool, + input: { value: 'x' }, + ctx: { ...context(), abortSignal: controller.signal }, + }); + // A deep-freeze of the context would have made this throw. + controller.abort(new Error('still live')); + assert.equal(controller.signal.aborted, true); + }); + + test('rejects duplicate canonical tool registrations', () => { + const authority = { prepare: async () => ({ claims: [], execute: async () => undefined }) }; + assert.throws( + () => + new ToolAuthorityRegistry([ + ['Write', authority], + ['Write', authority], + ]), + /already registered: Write/, + ); + }); + + test('extends registries immutably and still rejects duplicate ids', () => { + const first = { prepare: async () => ({ claims: [], execute: async () => undefined }) }; + const second = { prepare: async () => ({ claims: [], execute: async () => undefined }) }; + const base = new ToolAuthorityRegistry([['first', first]]); + const extended = base.withRegistrations([['second', second]]); + assert.equal(base.has('first'), true); + assert.equal(base.has('second'), false); + assert.equal(extended.has('first'), true); + assert.equal(extended.has('second'), true); + assert.throws(() => base.withRegistrations([['first', second]]), /already registered: first/); + }); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 2540f554b2..a6fb822e0d 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -136,6 +136,47 @@ describe('ToolRuntime settlement', () => { assert.deepEqual(settlement.result, { ok: true }); }); + it('passes the durable operation identity to a prepared effect after T1', async () => { + let preparedOperationId: string | undefined; + let effectOperationId: string | undefined; + const runtime = makeRuntime({ + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: { + commitToolPrepared: async (input) => { + preparedOperationId = input.operationId; + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }, + }); + + const settlement = await runtime.settleToolCall({ + tool: { + ...tool(() => assert.fail('the prepared effect owns execution')), + name: 'PreparedRead', + }, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'provider-call-reused', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + effect: async (_signal, _fallbackEffect, executionContext) => { + effectOperationId = executionContext.operationId; + return { ok: true }; + }, + }); + + assert.ok(preparedOperationId); + assert.equal(effectOperationId, preparedOperationId); + assert.notEqual(effectOperationId, 'provider-call-reused'); + assert.deepEqual(settlement.result, { ok: true }); + }); + it('cancels prepared Client Capability work when T1 fails', async () => { const order: string[] = []; const clientTool: MakaTool = { @@ -639,6 +680,38 @@ describe('ToolRuntime settlement', () => { 'child-session', ); }); + + it('precomputes exclusive-step admission in provider order', () => { + const normal = tool(async () => ({ ok: true })); + const exclusive: MakaTool = { + ...tool(async () => ({ ok: true })), + name: 'AskUserQuestion', + executionSemantics: 'exclusive_step', + }; + + assert.deepEqual(makeRuntime().admitToolCallBatch([exclusive, normal, normal], 'step-a'), [ + { kind: 'admitted' }, + { + kind: 'rejected', + reason: + 'Tool Read did not run: AskUserQuestion cannot share an assistant step with other tool calls. Send Read again in a later step.', + }, + { + kind: 'rejected', + reason: + 'Tool Read did not run: AskUserQuestion cannot share an assistant step with other tool calls. Send Read again in a later step.', + }, + ]); + assert.deepEqual(makeRuntime().admitToolCallBatch([normal, exclusive, normal], 'step-b'), [ + { kind: 'admitted' }, + { + kind: 'rejected', + reason: + 'Tool AskUserQuestion did not run: it cannot share an assistant step with other tool calls. Send AskUserQuestion again in a step where it is the only call.', + }, + { kind: 'admitted' }, + ]); + }); }); function makeRuntime( diff --git a/packages/runtime/src/__tests__/tool-scheduler.test.ts b/packages/runtime/src/__tests__/tool-scheduler.test.ts new file mode 100644 index 0000000000..7abc54eb2b --- /dev/null +++ b/packages/runtime/src/__tests__/tool-scheduler.test.ts @@ -0,0 +1,391 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { PreparedOperation, ResourceClaim } from '../preparation/types.js'; +import { ToolScheduler } from '../tool-scheduler.js'; + +const FILE = 'filesystem:workspace'; + +const read = (key: string): ResourceClaim[] => [ + { kind: 'keyed', authority: FILE, key, mode: 'read' }, +]; +const tree = (key: string): ResourceClaim[] => [ + { kind: 'keyed', authority: FILE, key, mode: 'read', scope: 'tree' }, +]; +const write = (key: string): ResourceClaim[] => [ + { kind: 'keyed', authority: FILE, key, mode: 'write' }, +]; +const all = (): ResourceClaim[] => [{ kind: 'all' }]; +const none = (): ResourceClaim[] => []; + +function operation( + claims: readonly ResourceClaim[], + run?: (signal?: AbortSignal) => Promise | Result, +): PreparedOperation { + return { + claims, + execute: (signal) => Promise.resolve(run ? run(signal) : (undefined as Result)), + }; +} + +// The Scheduler always calls task.run; route that through the op.execute so the +// test observes the operation's own effects. +const runThrough = + () => + (execution: PreparedOperation, signal?: AbortSignal) => + execution.execute(signal) as Promise; + +describe('ToolScheduler', () => { + test('starts non-conflicting (overlapping-reader) tasks immediately', async () => { + const scheduler = new ToolScheduler(); + const first = deferred(); + const second = deferred(); + const started: string[] = []; + + const firstResult = scheduler.add({ + id: 'read-a-1', + sequence: 0, + operation: operation(read('/repo/a'), () => { + started.push('first'); + return first.promise; + }), + run: runThrough(), + }); + const secondResult = scheduler.add({ + id: 'read-a-2', + sequence: 1, + operation: operation(read('/repo/a'), () => { + started.push('second'); + return second.promise; + }), + run: runThrough(), + }); + + assert.deepEqual(started, ['first', 'second']); + assert.equal(scheduler.activeCount, 2); + first.resolve('one'); + second.resolve('two'); + assert.deepEqual(await Promise.all([firstResult, secondResult]), ['one', 'two']); + assert.equal(scheduler.activeCount, 0); + }); + + test('preserves writer fairness while allowing independent work to bypass the queue', async () => { + const scheduler = new ToolScheduler(); + const reader = deferred(); + const writer = deferred(); + const laterReader = deferred(); + const independent = deferred(); + const started: string[] = []; + const task = ( + id: string, + sequence: number, + claims: readonly ResourceClaim[], + gate: ReturnType>, + ) => + scheduler.add({ + id, + sequence, + operation: operation(claims, () => { + started.push(id); + return gate.promise; + }), + run: runThrough(), + }); + + const results = [ + task('reader-1', 0, read('/repo/a'), reader), + task('writer', 1, write('/repo/a'), writer), + task('reader-2', 2, read('/repo/a'), laterReader), + task('independent', 3, write('/repo/b'), independent), + ]; + + assert.deepEqual(started, ['reader-1', 'independent']); + reader.resolve(); + await flushMicrotasks(); + assert.deepEqual(started, ['reader-1', 'independent', 'writer']); + writer.resolve(); + await flushMicrotasks(); + assert.deepEqual(started, ['reader-1', 'independent', 'writer', 'reader-2']); + laterReader.resolve(); + independent.resolve(); + await Promise.all(results); + }); + + test('one drain starts every newly unblocked non-conflicting task', async () => { + const scheduler = new ToolScheduler(); + const blocker = deferred(); + const a = deferred(); + const b = deferred(); + const started: string[] = []; + const results = [ + scheduler.add({ + id: 'all', + sequence: 0, + operation: operation(all(), () => { + started.push('all'); + return blocker.promise; + }), + run: runThrough(), + }), + scheduler.add({ + id: 'a', + sequence: 1, + operation: operation(write('/repo/a'), () => { + started.push('a'); + return a.promise; + }), + run: runThrough(), + }), + scheduler.add({ + id: 'b', + sequence: 2, + operation: operation(write('/repo/b'), () => { + started.push('b'); + return b.promise; + }), + run: runThrough(), + }), + ]; + + assert.deepEqual(started, ['all']); + blocker.resolve(); + await flushMicrotasks(); + assert.deepEqual(started, ['all', 'a', 'b']); + a.resolve(); + b.resolve(); + await Promise.all(results); + }); + + test('freezes further dispatch after a fatal rejection (fail-stop)', async () => { + const scheduler = new ToolScheduler(); + const started: string[] = []; + const first = scheduler.add({ + id: 'first', + sequence: 0, + operation: operation(write('/repo/a'), () => Promise.reject(new Error('async failure'))), + run: runThrough(), + }); + const second = scheduler.add({ + id: 'second', + sequence: 1, + operation: operation(read('/repo/a'), () => { + started.push('second'); + return 'ok'; + }), + run: runThrough(), + }); + + await assert.rejects(first, /async failure/); + // The frozen scheduler cancels queued work instead of running it. + await assert.rejects(second, /frozen|cancelled before it started/); + assert.deepEqual(started, []); + assert.equal(scheduler.queuedCount, 0); + }); + + test('rejects a task submitted after the scheduler is frozen', async () => { + const scheduler = new ToolScheduler(); + const first = scheduler.add({ + id: 'first', + sequence: 0, + operation: operation(write('/repo/a'), () => Promise.reject(new Error('boom'))), + run: runThrough(), + }); + await assert.rejects(first, /boom/); + await assert.rejects( + scheduler.add({ + id: 'late', + sequence: 1, + operation: operation(none(), () => 'late'), + run: runThrough(), + }), + /frozen/, + ); + }); + + test('cancels queued work without running it', async () => { + const scheduler = new ToolScheduler(); + const active = deferred(); + const controller = new AbortController(); + let queuedStarts = 0; + const activeResult = scheduler.add({ + id: 'active', + sequence: 0, + operation: operation(write('/repo/a'), () => active.promise), + run: runThrough(), + }); + const queuedResult = scheduler.add({ + id: 'queued', + sequence: 1, + operation: operation(write('/repo/a'), () => { + queuedStarts += 1; + }), + signal: controller.signal, + run: runThrough(), + }); + const queuedOutcome = Promise.allSettled([queuedResult]); + + controller.abort(new Error('turn stopped')); + assert.equal((await queuedOutcome)[0]?.status, 'rejected'); + assert.equal(queuedStarts, 0); + assert.equal(scheduler.queuedCount, 0); + active.resolve(); + await activeResult; + }); + + test('a fulfilled (abort-observed) active task releases the queue', async () => { + const scheduler = new ToolScheduler(); + const controller = new AbortController(); + const started: string[] = []; + const active = scheduler.add({ + id: 'active', + sequence: 0, + operation: operation( + write('/repo/a'), + () => + new Promise((resolve) => { + controller.signal.addEventListener('abort', () => resolve('cancelled-but-fulfilled'), { + once: true, + }); + }), + ), + signal: controller.signal, + run: runThrough(), + }); + const next = scheduler.add({ + id: 'next', + sequence: 1, + operation: operation(read('/repo/a'), () => { + started.push('next'); + return 'done'; + }), + run: runThrough(), + }); + + controller.abort(new Error('cancel active')); + assert.equal(await active, 'cancelled-but-fulfilled'); + assert.equal(await next, 'done'); + assert.deepEqual(started, ['next']); + }); + + test('a tree read conflicts with the in-tree writer but not a sibling tree read', async () => { + const scheduler = new ToolScheduler(); + const reader = deferred(); + const writer = deferred(); + const started: string[] = []; + const treeRead = scheduler.add({ + id: 'tree', + sequence: 0, + operation: operation(tree('/repo/src'), () => reader.promise), + run: runThrough(), + }); + const inTreeWrite = scheduler.add({ + id: 'write-a', + sequence: 1, + operation: operation(write('/repo/src/a.ts'), () => { + started.push('write-a'); + return writer.promise; + }), + run: runThrough(), + }); + const siblingRead = scheduler.add({ + id: 'src2-read', + sequence: 2, + operation: operation(tree('/repo/src2'), () => { + started.push('src2'); + return undefined; + }), + run: runThrough(), + }); + + assert.deepEqual(started, ['src2']); + reader.resolve(); + await flushMicrotasks(); + assert.deepEqual(started, ['src2', 'write-a']); + writer.resolve(); + await treeRead; + await inTreeWrite; + await siblingRead; + }); + + test('a Windows tree key conflicts with an in-tree Windows file key', async () => { + const scheduler = new ToolScheduler(); + const reader = deferred(); + const writer = deferred(); + const started: string[] = []; + const treeRead = scheduler.add({ + id: 'windows-tree', + sequence: 0, + operation: operation(tree('D:\\repo\\src'), () => reader.promise), + run: runThrough(), + }); + const inTreeWrite = scheduler.add({ + id: 'windows-write', + sequence: 1, + operation: operation(write('D:\\repo\\src\\a.ts'), () => { + started.push('windows-write'); + return writer.promise; + }), + run: runThrough(), + }); + + assert.deepEqual(started, []); + reader.resolve(); + await flushMicrotasks(); + assert.deepEqual(started, ['windows-write']); + writer.resolve(); + await Promise.all([treeRead, inTreeWrite]); + }); + + test('rejects duplicate or out-of-order sequence submission', () => { + const scheduler = new ToolScheduler(); + void scheduler.add({ + id: 'first', + sequence: 1, + operation: operation(none(), () => undefined), + run: runThrough(), + }); + assert.throws( + () => + scheduler.add({ + id: 'duplicate', + sequence: 1, + operation: operation(none(), () => undefined), + run: runThrough(), + }), + /strictly increasing sequence order/, + ); + }); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 2a065fffb2..9d226b5239 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -76,6 +76,7 @@ import { modelUsesNativeOpenAiResponses, resolveModelRuntime } from './model-run import { routeApplyPatchTools } from './apply-patch-profile.js'; import { bindToolResultArchiveDecoder } from './tool-result-archive-capability.js'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; +import { ToolPreparationService } from './preparation/tool-preparation-service.js'; export { DEFAULT_PERMISSION_TIMEOUT_MS, MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN, @@ -124,6 +125,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { // ── Process-singleton deps ───────────────────────────────────────────── /** Canonical-named tools available this session. */ tools: MakaTool[]; + /** Process-owned authority synthesis root; every backend receives the same instance. */ + preparationService: ToolPreparationService; /** Diagnostic-only Plan Mode/execution identity snapshot. */ planTraceContext?: { mode: 'agent' | 'plan'; @@ -257,6 +260,7 @@ export class AiSdkBackend implements AgentBackend { // Pulled out of the input for ergonomic access on hot paths. private readonly input: AiSdkBackendInput; + private readonly preparationService: ToolPreparationService; private readonly newId: () => string; private readonly now: () => number; private readonly maxSteps: number | undefined; @@ -298,7 +302,11 @@ export class AiSdkBackend implements AgentBackend { contextProviderDroppingReported: false, }; constructor(input: AiSdkBackendInput) { + if (!input.preparationService) { + throw new Error('AiSdkBackend requires a process-owned ToolPreparationService'); + } this.input = input; + this.preparationService = input.preparationService; this.sessionId = input.sessionId; this.newId = input.newId ?? (() => crypto.randomUUID()); this.now = input.now ?? (() => Date.now()); @@ -489,6 +497,7 @@ export class AiSdkBackend implements AgentBackend { compaction: this.compaction, toolAvailabilityRuntime: this.toolAvailabilityRuntime, codeCellAdmission: this.codeCellAdmission, + preparationService: this.preparationService, resolvedProviderOptions: this.resolvedProviderOptions, session: this.turnSessionState, newId: this.newId, diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index c501a66ac7..ea8696ba60 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -172,6 +172,9 @@ import { type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; +import { settleToolCallBatch } from './tool-call-batch.js'; +import { noneOperation } from './preparation/placeholder-authorities.js'; +import type { ToolPreparationService } from './preparation/tool-preparation-service.js'; import type { AiSdkBackendInput } from './ai-sdk-backend.js'; import { INVALID_TOOL_NAME, @@ -192,6 +195,7 @@ export interface AiSdkTurnDependencies { compaction: AiSdkCompaction; toolAvailabilityRuntime: ToolAvailabilityRuntime; codeCellAdmission: AdmissionLimiter; + preparationService: ToolPreparationService; resolvedProviderOptions: Record; session: AiSdkSessionState; newId: () => string; @@ -2235,59 +2239,129 @@ export class AiSdkTurn { await loadDurableTurnEvents(); } const toolsByName = new Map(providerTools.map((tool) => [tool.name, tool])); - const settlementOutcomes = await Promise.allSettled( - returnedToolCalls.map(async (toolCall) => { - if (toolCall.providerExecuted) { - throw new Error( - `Provider-executed tool call "${toolCall.toolName}" is outside the main-agent tool loop`, - ); - } - const sandboxBoundaryAttempt = isProviderSandboxBoundaryAttempt(toolCall); - const deniedBoundaryRequest = - toolRuntime.hasSandboxBoundaryDenial() && - toolCall.toolName.toLowerCase() === REQUEST_SANDBOX_BOUNDARY_TOOL_NAME; - if (deniedBoundaryRequest) { - toolRuntime.forceSandboxBoundaryFinalization(); - } - const blockedToolCall = sandboxBoundaryFinalizationStep || deniedBoundaryRequest; - const requestedTool = blockedToolCall - ? undefined - : toolsByName.get(toolCall.toolName); - const tool = requestedTool ?? toolsByName.get(INVALID_TOOL_NAME); - if (!tool) throw new Error('Runtime invalid-tool fallback is unavailable'); - const unavailableError = sandboxBoundaryFinalizationStep - ? 'Sandbox boundary finalization does not permit tool execution.' - : deniedBoundaryRequest - ? SANDBOX_BOUNDARY_DENIED_FOR_TURN - : 'returned tool is unavailable'; - return await toolRuntime.settleToolCall({ - tool, - turnId, - stepId: providerStepId, - toolCallId: toolCall.toolCallId, - // Provider metadata is persisted verbatim into an immutable - // RuntimeEvent, and a field the response did not carry - // arrives as an explicit `undefined` — which JSON drops, so - // the event no longer reads back as it was written and the - // store refuses it. One refusal took every tool-calling turn - // with it. - ...(toolCall.providerOptions !== undefined - ? { - providerOptions: stripUndefinedDeep(toolCall.providerOptions), - } - : {}), - input: - requestedTool !== undefined - ? toolCall.input - : { - tool: toolCall.toolName, - error: unavailableError, - ...(sandboxBoundaryAttempt ? { sandboxBoundaryAttempt: true } : {}), - }, - abortSignal: turnAbortController.signal, - eventSink: queue, - }); - }), + const preparedToolCalls = returnedToolCalls.map((toolCall) => { + const sandboxBoundaryAttempt = isProviderSandboxBoundaryAttempt(toolCall); + const deniedBoundaryRequest = + toolRuntime.hasSandboxBoundaryDenial() && + toolCall.toolName.toLowerCase() === REQUEST_SANDBOX_BOUNDARY_TOOL_NAME; + if (deniedBoundaryRequest) { + toolRuntime.forceSandboxBoundaryFinalization(); + } + const blockedToolCall = sandboxBoundaryFinalizationStep || deniedBoundaryRequest; + const requestedTool = blockedToolCall + ? undefined + : toolsByName.get(toolCall.toolName); + const tool = requestedTool ?? toolsByName.get(INVALID_TOOL_NAME); + if (!tool) throw new Error('Runtime invalid-tool fallback is unavailable'); + const unavailableError = sandboxBoundaryFinalizationStep + ? 'Sandbox boundary finalization does not permit tool execution.' + : deniedBoundaryRequest + ? SANDBOX_BOUNDARY_DENIED_FOR_TURN + : 'returned tool is unavailable'; + return { + toolCall, + tool, + input: + requestedTool !== undefined + ? toolCall.input + : { + tool: toolCall.toolName, + error: unavailableError, + ...(sandboxBoundaryAttempt ? { sandboxBoundaryAttempt: true } : {}), + }, + syntheticWithoutEffect: + blockedToolCall || requestedTool === undefined || toolCall.providerExecuted, + }; + }); + const admissionCandidates = preparedToolCalls.filter( + ({ toolCall }) => !toolCall.providerExecuted, + ); + const candidateAdmissions = toolRuntime.admitToolCallBatch( + admissionCandidates.map(({ tool }) => tool), + providerStepId, + ); + let admissionIndex = 0; + const admittedToolCalls = preparedToolCalls.map((prepared) => ({ + ...prepared, + admission: prepared.toolCall.providerExecuted + ? ({ kind: 'admitted' } as const) + : candidateAdmissions[admissionIndex++]!, + })); + const settlementOutcomes = await settleToolCallBatch( + admittedToolCalls.map( + ({ toolCall, tool, input: executionInput, admission, syntheticWithoutEffect }) => ({ + id: toolCall.toolCallId, + signal: turnAbortController.signal, + prepare: async () => { + if (syntheticWithoutEffect || admission.kind === 'rejected') { + // No side effect to prepare; still returns none() claims so + // the Scheduler does not block this call. + return noneOperation(); + } + // The synthesis root is the ONLY dispatch entry: it + // validates, canonicalises and resolves the process-owned + // authority registry (or the none() placeholder). + return await this.deps.preparationService.prepare({ + tool, + input: executionInput, + ctx: { + sessionId: this.deps.backend.sessionId, + ...(this.runId ? { runId: this.runId } : {}), + turnId, + cwd: this.deps.backend.header.cwd, + executionBoundary: await this.deps.backend.readExecutionBoundary(), + permissionMode: this.deps.backend.header.permissionMode, + toolCallId: toolCall.toolCallId, + abortSignal: turnAbortController.signal, + emitOutput: () => {}, + }, + }); + }, + run: async (operation) => { + if (toolCall.providerExecuted) { + throw new Error( + `Provider-executed tool call "${toolCall.toolName}" is outside the main-agent tool loop`, + ); + } + return await toolRuntime.settleToolCall({ + tool, + turnId, + stepId: providerStepId, + stepAdmission: admission, + toolCallId: toolCall.toolCallId, + // Provider metadata is persisted verbatim into an immutable + // RuntimeEvent, and a field the response did not carry + // arrives as an explicit `undefined` — which JSON drops, so + // the event no longer reads back as it was written and the + // store refuses it. One refusal took every tool-calling turn + // with it. + ...(toolCall.providerOptions !== undefined + ? { + providerOptions: stripUndefinedDeep(toolCall.providerOptions), + } + : {}), + input: executionInput, + abortSignal: turnAbortController.signal, + eventSink: queue, + // Claims decide ordering only. Every successfully prepared + // operation owns execution, including an operation with no + // claims. Placeholder operations invoke fallbackEffect so + // the original impl still receives the live ToolRuntime + // context rather than the preparation-time stub context. + ...(operation + ? { + effect: ( + signal: AbortSignal, + fallbackEffect: () => Promise, + executionContext: MakaToolContext, + ) => operation.execute(signal, fallbackEffect, executionContext), + } + : {}), + }); + }, + }), + ), + { processAdmission: this.deps.preparationService.processAdmission }, ); const rejectedSettlement = settlementOutcomes.find( (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', @@ -2722,10 +2796,20 @@ export class AiSdkTurn { const tool = snapshot.get(name); if (!tool) throw new Error(`Tool "${name}" is not active or nestable in this cell`); const parsedInput = await validateCodeModeToolInput(tool, input); + const nestedToolCallId = `${context.toolCallId}:nested:${this.deps.newId()}`; + const operation = await this.deps.preparationService.prepare({ + tool, + input: parsedInput, + ctx: { + ...context, + toolCallId: nestedToolCallId, + abortSignal: signal, + }, + }); const settlement = await this.toolRuntime.settleToolCall({ tool, turnId: context.turnId, - toolCallId: `${context.toolCallId}:nested:${this.deps.newId()}`, + toolCallId: nestedToolCallId, input: parsedInput, abortSignal: signal, eventSink: nestedEventSink, @@ -2733,6 +2817,8 @@ export class AiSdkTurn { parentToolCallId: context.toolCallId, ...(context.operationId ? { parentOperationId: context.operationId } : {}), maxResultBytes: DEFAULT_CODE_MODE_EXECUTION_POLICY.maxToolOutputBytes, + effect: (executionSignal, fallbackEffect, executionContext) => + operation.execute(executionSignal, fallbackEffect, executionContext), }); if (settlement.providerError !== undefined) { throw new Error(settlement.providerError); diff --git a/packages/runtime/src/apply-patch-batch.ts b/packages/runtime/src/apply-patch-batch.ts index 37ec4dcd17..48bcf2f833 100644 --- a/packages/runtime/src/apply-patch-batch.ts +++ b/packages/runtime/src/apply-patch-batch.ts @@ -17,6 +17,7 @@ * under the License. */ +import { ToolOutcomeUnknownError } from '@maka/core/events'; import type { ApplyPatchOperation } from './filesystem-executor.js'; import { formatSyntheticToolErrorText } from './tool-runtime.js'; @@ -48,6 +49,23 @@ function operationFact(operation: ApplyPatchOperation): AppliedPatchOperationFac return { type: operation.type, path: operation.path }; } +export class ApplyPatchBatchOutcomeUnknownError extends ToolOutcomeUnknownError { + readonly applied: readonly AppliedPatchOperationFact[]; + readonly uncertain: AppliedPatchOperationFact; + + constructor(input: { + applied: readonly AppliedPatchOperationFact[]; + uncertain: AppliedPatchOperationFact; + cause: ToolOutcomeUnknownError; + }) { + super(`ApplyPatch outcome is unknown for ${input.uncertain.type} ${input.uncertain.path}.`, { + cause: input.cause, + }); + this.applied = Object.freeze(input.applied.map((item) => Object.freeze({ ...item }))); + this.uncertain = Object.freeze({ ...input.uncertain }); + } +} + /** Execute one parsed patch in order while preserving the exact committed prefix. */ export async function executeApplyPatchOperations( operations: readonly ApplyPatchOperation[], @@ -75,6 +93,13 @@ export async function executeApplyPatchOperations( applied.push(operationFact(operation)); } catch (error) { const failed = operationFact(operation); + if (error instanceof ToolOutcomeUnknownError) { + throw new ApplyPatchBatchOutcomeUnknownError({ + applied, + uncertain: failed, + cause: error, + }); + } const appliedText = applied.length ? ` Applied before failure: ${applied.map((item) => `${item.type} ${item.path}`).join(', ')}.` : ' No file operation was applied.'; diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index 18ddde6aff..df83104098 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -45,7 +45,6 @@ import { bashToolResultToModelOutput } from './bash-model-output.js'; import { fileWriteToolResultToModelOutput } from './file-tool-model-output.js'; import { openAiApplyPatchInputSchema } from './openai-apply-patch.js'; import { parseCodexV4aPatch } from './codex-v4a-patch.js'; -import { executeApplyPatchOperations } from './apply-patch-batch.js'; import { buildManagedBashTool, buildStopBackgroundTaskTool, @@ -66,9 +65,25 @@ import { type WorkspaceExecutor, } from './workspace-executor.js'; import { - createBoundaryFilesystemExecutor, + createFilesystemResourceOwner, type FilesystemExecuteInput, + type FilesystemResourceAuthority, + type FilesystemResult, } from './filesystem-executor.js'; +import type { FilesystemLeaseCoordinator } from './filesystem-lease-coordinator.js'; +import type { ProcessResourceAdmissionCoordinator } from './process-resource-admission.js'; +import { noneOperation } from './preparation/placeholder-authorities.js'; +import { defaultToolAuthorityRegistrations } from './preparation/default-tool-authorities.js'; +import { + ToolAuthorityRegistry, + type RegisteredToolAuthority, + type ToolAuthorityRegistration, +} from './preparation/tool-authority-registry.js'; +import type { + AuthorityContext, + PreparedOperationExecutionContext, + ResourceAuthority, +} from './preparation/types.js'; // tool-runtime.ts is the single source of truth for the tool shape; this // re-export only keeps back-compat for callers that imported from @@ -180,6 +195,10 @@ export interface BuildBuiltinToolsOptions { sandboxManager?: SandboxManager; /** Sandboxed worker used for all local filesystem tools. */ filesystemWorker?: Pick; + /** Process-wide correctness owner shared by direct and prepared file tools. */ + filesystemLeaseCoordinator?: FilesystemLeaseCoordinator; + /** Process-wide shared/exclusive barrier shared by all authority paths. */ + processResourceAdmissionCoordinator?: ProcessResourceAdmissionCoordinator; /** Test/embedding override. Production callers use the current process platform. */ sandboxPlatform?: SandboxPlatform; snapshotImage?: (input: { @@ -191,13 +210,35 @@ export interface BuildBuiltinToolsOptions { releaseImageSnapshot?: (input: { sessionId: string; refId: string }) => Promise; } -export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaTool[] { +interface AuthorityBoundMakaTool extends MakaTool { + readonly resourceAuthority?: RegisteredToolAuthority | undefined; +} + +export interface BuiltinToolComposition { + readonly tools: MakaTool[]; + readonly authorityRegistry: ToolAuthorityRegistry; +} + +function buildBuiltinToolDefinitions( + options: BuildBuiltinToolsOptions, + includeAuthorities: boolean, +): AuthorityBoundMakaTool[] { const executor = options.executor ?? createLocalWorkspaceExecutor(); - const filesystem = createBoundaryFilesystemExecutor({ + const filesystemOwner = createFilesystemResourceOwner({ workspace: executor, ...(options.filesystemWorker ? { worker: options.filesystemWorker } : {}), ...(options.permissionProfile ? { permissionProfile: options.permissionProfile } : {}), + ...(options.filesystemLeaseCoordinator + ? { filesystemLeaseCoordinator: options.filesystemLeaseCoordinator } + : {}), + ...(options.processResourceAdmissionCoordinator + ? { + processResourceAdmissionCoordinator: options.processResourceAdmissionCoordinator, + } + : {}), }); + const filesystem = filesystemOwner.executor; + const filesystemAuthority = includeAuthorities ? filesystemOwner.authority : undefined; const executionFacts = executor.facts; const acceptsResourceRefs = Boolean(options.runtimeResources || options.attachmentResources); const readDescription = `Read a text file${options.snapshotImage ? ' or supported image' : ''} from disk${acceptsResourceRefs ? ', or read a whole runtime resource using ref' : ''}.`; @@ -325,29 +366,75 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ...(options.backgroundTasks ? [buildStopBackgroundTaskTool(options.backgroundTasks)] : []), ...(options.ptyControls ? [buildWriteStdinTool(options.ptyControls)] : []), ]; + // Provider-native ApplyPatch uses `providerTool` for its model-facing schema, + // while Runtime must also accept historical/freeform Codex patch strings. + // Keep the provider JSON schema for diagnostics and widen only validation at + // the execution boundary. + const applyPatchRuntimeInputSchema = { + jsonSchema: (openAiApplyPatchInputSchema as { readonly jsonSchema?: unknown }).jsonSchema, + async validate(value: unknown) { + if (typeof value === 'string') return { success: true as const, value }; + const validate = ( + openAiApplyPatchInputSchema as { + validate?: ( + candidate: unknown, + ) => + | { success: true; value: unknown } + | { success: false; error: unknown } + | Promise<{ success: true; value: unknown } | { success: false; error: unknown }>; + } + ).validate; + if (!validate) return { success: true as const, value }; + return await validate(value); + }, + }; const applyPatchTool = { name: 'apply_patch', activityKind: 'edit', categoryHint: 'file_write', description: 'Apply one or more file changes using the selected provider patch protocol.', - parameters: openAiApplyPatchInputSchema, + parameters: applyPatchRuntimeInputSchema, providerTool: { kind: 'openai-apply-patch' }, executionFacts, + resourceAuthority: filesystemAuthority + ? { + prepare: async (input, ctx) => { + if (typeof input === 'string') { + return await filesystemAuthority.preparePatchBatch(parseCodexV4aPatch(input), ctx); + } + const operation = + input && typeof input === 'object' && 'operation' in input + ? (input as { operation: { type: string; path: string; diff?: string } }).operation + : undefined; + if (!operation) throw new Error('ApplyPatch input did not contain an operation.'); + const raw = await filesystemAuthority.prepare( + { operation, ...filesystemCall(ctx) } as never, + ctx, + ); + return { + claims: raw.claims, + execute: async (signal) => { + const result = (await raw.execute(signal)) as FilesystemResult; + if (result.kind !== 'apply_patch') { + throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`); + } + return { status: 'completed' as const }; + }, + }; + }, + } + : undefined, impl: async (input, ctx) => { if (typeof input !== 'string') { return await filesystem.applyPatch({ operation: input.operation, ...filesystemCall(ctx) }); } - const operations = parseCodexV4aPatch(input); - return await executeApplyPatchOperations( - operations, - async (operation) => { - await filesystem.applyPatch({ operation, ...filesystemCall(ctx) }); - }, - ctx.abortSignal, - ); + return await filesystem.applyPatchBatch({ + operations: parseCodexV4aPatch(input), + ...filesystemCall(ctx), + }); }, - } satisfies MakaTool; - const tools: MakaTool[] = [ + } satisfies AuthorityBoundMakaTool; + const tools: AuthorityBoundMakaTool[] = [ ...bashTools, ...backgroundTools, { @@ -385,6 +472,45 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT }, } : {}), + resourceAuthority: filesystemToolAuthority( + filesystemAuthority, + (args: { path: string; offset?: number; limit?: number }, ctx) => ({ + operation: { + kind: 'read', + path: args.path, + ...(args.offset !== undefined ? { offset: args.offset } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), + }, + ...filesystemCall(ctx), + }), + async (result, _args, ctx, executionContext) => { + const r = result as FilesystemResult; + if (r.kind === 'read_image') { + if (!options.snapshotImage) { + throw new Error('Read image snapshots are not available in this toolset.'); + } + if (!executionContext?.operationId) { + throw new Error('Read image snapshots require a durable tool operation identity.'); + } + const ref = await options.snapshotImage({ + sessionId: ctx.sessionId, + ownerId: executionContext.operationId, + bytes: r.bytes, + mimeType: r.mimeType, + }); + return { kind: 'image' as const, mimeType: r.mimeType, ref }; + } + if (r.kind !== 'read') { + throw internalFilesystemReadFailure( + 'Read', + 'no file content came back', + 'the file is empty or missing', + ); + } + return { content: r.content }; + }, + (args) => typeof args === 'object' && args !== null && 'ref' in args, + ), impl: async (input, ctx) => { const { cwd, sessionId, abortSignal } = ctx; if ('ref' in input) { @@ -459,6 +585,22 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT content: z.string(), }), executionFacts, + resourceAuthority: filesystemToolAuthority( + filesystemAuthority, + (args: { path: string; content: string }, ctx) => ({ + operation: { kind: 'write', path: args.path, content: args.content }, + ...filesystemCall(ctx), + }), + (result) => { + const r = result as Extract; + if (r.kind !== 'write') + throw internalFilesystemWriteFailure('Write', 'the file was written'); + if (r.diff !== undefined) { + return { kind: 'file_diff' as const, paths: [r.path], diff: r.diff }; + } + return { kind: 'file_write' as const, path: r.path, bytes: r.bytes }; + }, + ), impl: async ({ path, content }, ctx) => { const result = await filesystem.execute({ operation: { kind: 'write', path, content }, @@ -487,6 +629,39 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT new_string: z.string(), }), executionFacts, + resourceAuthority: filesystemToolAuthority( + filesystemAuthority, + (args: { path: string; old_string: string; new_string: string }, ctx) => ({ + operation: { + kind: 'edit', + path: args.path, + oldString: args.old_string, + newString: args.new_string, + }, + ...filesystemCall(ctx), + }), + (result) => { + const r = result as Extract; + if (r.kind !== 'edit') { + throw internalFilesystemWriteFailure( + 'Edit', + 'the edit was applied', + 'a different old_string will not help', + ); + } + if (r.diff !== undefined) { + return { kind: 'file_diff' as const, paths: [r.path], diff: r.diff }; + } + return { + ok: r.ok, + path: r.path, + replacements: r.replacements, + matchedVia: r.matchedVia, + startLine: r.startLine, + endLine: r.endLine, + }; + }, + ), impl: async ({ path, old_string, new_string }, ctx) => { const result = await filesystem.execute({ operation: { @@ -538,6 +713,24 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT .describe('Sort object keys lexicographically; default false.'), }), executionFacts, + resourceAuthority: filesystemToolAuthority( + filesystemAuthority, + (args: { path: string; sort_keys?: boolean }, ctx) => ({ + operation: { kind: 'format_json', path: args.path, sortKeys: args.sort_keys ?? false }, + ...filesystemCall(ctx), + }), + (result) => { + const r = result as Extract; + if (r.kind !== 'format_json') { + throw internalFilesystemWriteFailure('FormatJson', 'the file was rewritten'); + } + if (r.diff !== undefined) { + return { kind: 'file_diff' as const, paths: [r.path], diff: r.diff }; + } + const { kind: _kind, ...diagnostic } = r; + return diagnostic; + }, + ), impl: async ({ path, sort_keys }, ctx) => { const result = await filesystem.execute({ operation: { @@ -578,6 +771,24 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ), }), executionFacts, + resourceAuthority: filesystemToolAuthority( + filesystemAuthority, + (args: { pattern: string; cwd?: string }, ctx) => ({ + operation: { kind: 'glob', path: args.cwd ?? '.', pattern: args.pattern, limit: 200 }, + ...filesystemCall(ctx), + }), + (result) => { + const r = result as Extract; + if (r.kind !== 'glob') { + throw internalFilesystemReadFailure( + 'Glob', + 'no file list came back', + 'no files match the pattern', + ); + } + return { files: r.files }; + }, + ), impl: async ({ pattern, cwd: relCwd }, ctx) => { const result = await filesystem.execute({ operation: { kind: 'glob', path: relCwd ?? '.', pattern, limit: 200 }, @@ -602,6 +813,32 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT glob: z.string().optional(), }), executionFacts, + resourceAuthority: filesystemToolAuthority( + filesystemAuthority, + (args: { pattern: string; path?: string; glob?: string }, ctx) => ({ + operation: { + kind: 'grep', + path: args.path ?? '.', + pattern: args.pattern, + ...(args.glob ? { glob: args.glob } : {}), + maxCountPerFile: 50, + limit: 200, + timeoutMs: GREP_TIMEOUT_MS, + }, + ...filesystemCall(ctx), + }), + (result) => { + const r = result as Extract; + if (r.kind !== 'grep') { + throw internalFilesystemReadFailure( + 'Grep', + 'no search result came back', + 'the pattern is absent', + ); + } + return { matches: r.matches }; + }, + ), impl: async ({ pattern, path, glob }, ctx) => { // Self-bound: ripgrep finishes in well under a second normally, but a // pathological tree (network mount, /proc, a FIFO) could hang it. The @@ -632,9 +869,33 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT return tools; } -/** The per-call context every file tool hands to the filesystem authority. */ +/** + * Compose the builtin tool surface and its canonical authority registrations + * from the same definitions. The Host retains the registry for the process; + * only the stripped tool declarations are exposed to model backends. + */ +export function buildBuiltinToolComposition( + options: BuildBuiltinToolsOptions = {}, +): BuiltinToolComposition { + const tools = buildBuiltinToolDefinitions(options, true); + const registrations = tools.flatMap((tool) => + tool.resourceAuthority ? [[tool.name, tool.resourceAuthority]] : [], + ); + return { + tools: tools.map(stripResourceAuthority), + authorityRegistry: new ToolAuthorityRegistry(registrations).withRegistrations( + defaultToolAuthorityRegistrations(options.processResourceAdmissionCoordinator), + ), + }; +} + +export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaTool[] { + return buildBuiltinToolDefinitions(options, false).map(stripResourceAuthority); +} + +/** The per-call context every file authority adapter hands to its domain authority. */ function filesystemCall( - ctx: MakaToolContext, + ctx: Pick, ): Pick { return { cwd: ctx.cwd, @@ -644,6 +905,44 @@ function filesystemCall( }; } +/** + * Adapt a tool's public arguments and result shape to the filesystem domain + * authority. The adapter lives only in ToolAuthorityRegistry, never on the + * MakaTool exposed to a backend. + */ +function filesystemToolAuthority( + authority: FilesystemResourceAuthority | undefined, + buildInput: (args: Args, ctx: AuthorityContext) => unknown, + reshape: ( + result: unknown, + args: Args, + ctx: AuthorityContext, + executionContext?: PreparedOperationExecutionContext, + ) => Promise | unknown, + none?: (args: Args) => boolean, +): ResourceAuthority | undefined { + if (!authority) return undefined; + return { + async prepare(args, ctx) { + if (none && none(args as Args)) return noneOperation(); + const input = buildInput(args as Args, ctx); + const raw = await authority.prepare(input as never, ctx); + return { + claims: raw.claims, + execute: async (signal, fallbackEffect, executionContext) => { + const result = await raw.execute(signal, fallbackEffect, executionContext); + return reshape(result, args as Args, ctx, executionContext); + }, + }; + }, + }; +} + +function stripResourceAuthority(tool: AuthorityBoundMakaTool): MakaTool { + const { resourceAuthority: _resourceAuthority, ...declaration } = tool; + return declaration; +} + interface ExecutorBashSandboxOptions { permissionProfile?: PermissionProfile; sandboxManager?: SandboxManager; diff --git a/packages/runtime/src/file-stable-read.ts b/packages/runtime/src/file-stable-read.ts new file mode 100644 index 0000000000..48f4de843c --- /dev/null +++ b/packages/runtime/src/file-stable-read.ts @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { constants } from 'node:fs'; +import { open, type FileHandle } from 'node:fs/promises'; +import { isSupportedImagePath, validateImageBytes, type ImageMimeType } from './image-file.js'; +import { StableWriteFailure } from './file-stable-write.js'; + +export type StableReadExpectedIdentity = { readonly dev: string; readonly ino: string } | 'missing'; + +/** + * Open and pin the object observed at filesystem admission. The descriptor + * validation is the load-bearing check: pathname replacement between resolve + * and open cannot redirect the subsequent read to another inode. + */ +export async function openStableReadTarget(input: { + path: string; + expectedIdentity: StableReadExpectedIdentity; +}): Promise { + const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; + // When admission also saw missing, an unchanged path naturally preserves the + // ordinary ENOENT result from open(). + const handle: FileHandle = await open(input.path, constants.O_RDONLY | noFollow); + + if (input.expectedIdentity === 'missing') { + await handle.close(); + throw new StableWriteFailure( + 'path_changed', + 'The target appeared after filesystem admission; the replacement was not read.', + ); + } + + try { + const metadata = await handle.stat({ bigint: true }); + if ( + String(metadata.dev) !== input.expectedIdentity.dev || + String(metadata.ino) !== input.expectedIdentity.ino + ) { + throw new StableWriteFailure( + 'path_changed', + 'The approved filesystem target changed before it could be read.', + ); + } + return handle; + } catch (error) { + await handle.close(); + throw error; + } +} + +export async function readStableTarget(input: { + path: string; + expectedIdentity: StableReadExpectedIdentity; + offset?: number; + limit?: number; +}): Promise< + { readonly content: string } | { readonly bytes: Uint8Array; readonly mimeType: ImageMimeType } +> { + const handle = await openStableReadTarget(input); + try { + if (isSupportedImagePath(input.path)) { + return validateImageBytes(await handle.readFile()); + } + const content = await handle.readFile('utf8'); + if (input.offset === undefined && input.limit === undefined) return { content }; + const lines = content.split('\n'); + const start = input.offset ?? 0; + const end = input.limit ? start + input.limit : lines.length; + return { content: lines.slice(start, end).join('\n') }; + } finally { + await handle.close(); + } +} diff --git a/packages/runtime/src/file-stable-write.ts b/packages/runtime/src/file-stable-write.ts index 04a21a9873..bc7ef9fba5 100644 --- a/packages/runtime/src/file-stable-write.ts +++ b/packages/runtime/src/file-stable-write.ts @@ -105,7 +105,7 @@ export async function openStableTarget(input: { if (input.targetType !== undefined && input.targetType !== 'missing') { // An existing target with no identity: the caller explicitly opted out of // CAS (#3484). Open without truncation but perform no comparison — the - // caller's own absence of a T0 snapshot is the contract. + // caller's own admitted-missing observation is the contract. return openExistingNoTruncate(input.path); } try { diff --git a/packages/runtime/src/file-write-lock.ts b/packages/runtime/src/file-write-lock.ts index 799d844017..0c83e21734 100644 --- a/packages/runtime/src/file-write-lock.ts +++ b/packages/runtime/src/file-write-lock.ts @@ -18,46 +18,28 @@ */ // packages/runtime/src/file-write-lock.ts -// Serialize file-mutating tools (Write/Edit) per file. The AI SDK runs a single -// step's tool calls concurrently, so two edits to one file would race on the -// read-modify-write (read -> replace -> write back) and silently lose an update. -// withFileWriteLock(key, fn) runs work sharing a key strictly one-at-a-time, in -// submission order; distinct keys run concurrently. Callers pass a key that -// uniquely identifies the target file within their tool surface. Runtime tools -// key on the resolved absolute path. A failed task never wedges its key, and keys are reclaimed -// once their chain drains, so the map stays bounded. -// -// Keying is lexical, so one file reached under two names — via a symlinked parent -// dir, a hard link, or a case-insensitive filesystem ("a.txt" vs "A.txt") — takes -// two keys and is not merged. This matches opencode's lexical (path.resolve) -// per-file Semaphore. (Bash is not serialized either — a per-file lock cannot key -// arbitrary shell.) +// Compatibility adapter for callers that still request an exact file write +// lock directly. The queue is owned by processFilesystemLeases, so legacy +// writers conflict with the read/tree/write leases used by builtin tools. -const tails = new Map>(); +import { processFilesystemLeases } from './filesystem-lease-coordinator.js'; +import { hostFilesystemLeaseKey } from './filesystem-lease-key.js'; +import { processResourceAdmissions } from './process-resource-admission.js'; /** * Runs `fn` exclusively for `key`: it waits until any prior work for `key` * settles, then runs, then releases the key for the next waiter. Distinct keys * never block each other. * - * @internal File tools own the keying, so an external caller would only share - * this process-global queue by accident. + * `key` must be a canonical absolute host path. The adapter applies the same + * Windows case fold as the filesystem owner before entering the shared queue. */ export function withFileWriteLock(key: string, fn: () => Promise): Promise { - const prev = tails.get(key) ?? Promise.resolve(); - // Run fn after prev settles either way: a prior failed task must not wedge the - // key. `prev.then(fn, fn)` ignores prev's outcome and just sequences. - const run = prev.then(fn, fn); - // The next waiter chains off `tail`, which tracks completion only (swallowing - // result and error) so one task's rejection never propagates down the chain. - const tail = run.then( - () => {}, - () => {}, + return processResourceAdmissions.withShared(undefined, () => + processFilesystemLeases.withLease( + { key: hostFilesystemLeaseKey(key), mode: 'write', scope: 'exact' }, + undefined, + fn, + ), ); - tails.set(key, tail); - void tail.then(() => { - // Drop the key once nobody chained after us, so the map stays bounded. - if (tails.get(key) === tail) tails.delete(key); - }); - return run; } diff --git a/packages/runtime/src/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index 53fd789449..528b71576e 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -43,7 +43,31 @@ import { } from './filesystem-authority.js'; import { StableWriteFailure } from './file-stable-write.js'; import { applyUpdateToContent } from './apply-patch-file.js'; -import { withFileWriteLock } from './file-write-lock.js'; +import { executeApplyPatchOperations, type ApplyPatchBatchResult } from './apply-patch-batch.js'; +import { + normalizeFilesystemLeaseRequests, + processFilesystemLeases, + type FilesystemLeaseCoordinator, + type FilesystemLeaseRequest, +} from './filesystem-lease-coordinator.js'; +import { hostFilesystemLeaseKey } from './filesystem-lease-key.js'; +import { + processResourceAdmissions, + type ProcessResourceAdmissionCoordinator, +} from './process-resource-admission.js'; +import { + identityChanged, + type AdmittedTargetContract, + type ResolvedTarget, + type TargetIdentity, +} from './preparation/target-identity.js'; +import type { + AuthorityContext, + KeyedResourceClaim, + PreparedOperation, + ResourceAuthority, +} from './preparation/types.js'; +import { oneShotOperation } from './preparation/one-shot-operation.js'; import type { FilesystemWorkerClient, FilesystemWorkerClientOperation, @@ -60,6 +84,7 @@ import type { WorkspacePathScope, WorkspaceReadModifyWriteExecutor, WorkspaceSearchExecutor, + WorkspaceStableReadExecutor, WorkspaceWriteExecutor, } from './workspace-executor.js'; @@ -101,6 +126,10 @@ export interface FilesystemApplyPatchInput extends Omit { + operations: readonly ApplyPatchOperation[]; +} + export interface ApplyPatchResult { status: 'completed'; } @@ -113,6 +142,7 @@ export interface FilesystemExecutor { */ execute(input: FilesystemExecuteInput): Promise; applyPatch(input: FilesystemApplyPatchInput): Promise; + applyPatchBatch(input: FilesystemApplyPatchBatchInput): Promise; } /** The workspace primitives the host-local backend drives. */ @@ -120,6 +150,7 @@ export type FilesystemWorkspaceExecutor = WorkspaceWriteExecutor & WorkspaceEditExecutor & Partial & Partial & + Partial & WorkspaceSearchExecutor; export interface BoundaryFilesystemExecutorInput { @@ -127,6 +158,11 @@ export interface BoundaryFilesystemExecutorInput { worker?: Pick; /** Explicit embedding policy handed to the worker instead of a mode default. */ permissionProfile?: PermissionProfile; + filesystemLeaseCoordinator?: FilesystemLeaseCoordinator; + /** Shared side of the process-wide all() correctness barrier. */ + processResourceAdmissionCoordinator?: ProcessResourceAdmissionCoordinator; + /** @internal Deterministic admission-to-effect race gate for filesystem tests. */ + beforeTargetEffectForTest?: (target: AdmittedTargetContract) => void | Promise; } /** @@ -142,23 +178,16 @@ function pathScopeForBoundary(boundary: ExecutionBoundary | undefined): Workspac } /** - * Operations that read, modify and write back, and so must hold the target's lock. - * The single authority on which kinds are writes is `operationAccess` in the - * worker protocol; `mutates` was a second, narrower list that drifted. - */ - -/** - * Capture the target's stable identity at lock acquisition (T0) — *before* - * waiting for the write lock. This is the inode the worker compare-and-swaps - * against, so a path replaced while the call is queued for the lock is detected - * rather than silently written. Returns undefined when the target does not yet - * exist (a create), since there is no inode to pin. + * Capture the target identity for a resolve observation. Prepare discards this + * mutable field; admission retains it and the backend validates it on the pinned + * handle. Returns undefined when the target does not yet exist (a create), since + * there is no inode to pin. * * `follow` must match how the worker derives the targetType: content operations * follow the final symlink (stat), create/delete pin the directory entry (lstat) * so a swapped link is detected against the entry's own inode. */ -async function captureIdentityAtLockAcquisition( +async function captureFilesystemTargetIdentity( canonicalPath: string, follow: boolean, ): Promise { @@ -185,9 +214,22 @@ async function captureIdentityAtLockAcquisition( * - absent → the worker when one is wired, otherwise workspace-scoped local * execution. This is the embedding default and deliberately the narrow one. */ -export function createBoundaryFilesystemExecutor( - input: BoundaryFilesystemExecutorInput, -): FilesystemExecutor { +interface FilesystemBackend { + run( + call: FilesystemBackendExecuteInput, + target: AdmittedTargetContract, + ): Promise; + resolveTarget(input: { + cwd: string; + path: string; + semantics: 'target' | 'entry'; + executionBoundary?: ExecutionBoundary; + permissionMode?: PermissionMode; + abortSignal?: AbortSignal; + }): Promise; +} + +function buildFilesystemBackend(input: BoundaryFilesystemExecutorInput): FilesystemBackend { const local = createWorkspaceFilesystemExecutor(input.workspace); /** The worker that owns this boundary, or undefined when the workspace backend does. */ const workerFor = ( @@ -208,19 +250,43 @@ export function createBoundaryFilesystemExecutor( }; async function run( call: FilesystemBackendExecuteInput, - expectedIdentity?: FilesystemTargetIdentity, + target: AdmittedTargetContract, ): Promise { + await input.beforeTargetEffectForTest?.(target); const worker = workerFor(call.executionBoundary); if (!worker) { - // The local backend consumes the same identity authority as the worker - // (#2600): the pinned read-modify-write validates the T0 identity on the - // descriptor. Remote/isolated workspaces without readModifyWrite stay on - // the path-based fallback, documented as unprotected by the authority. - return await local.execute( + // Local stableReadFile/readModifyWrite implementations pin the admitted + // identity on a descriptor. External workspaces without those optional + // primitives remain a weaker path-based backend. + const result = await local.execute( call, pathScopeForBoundary(call.executionBoundary), - expectedIdentity, + target, ); + if (call.operation.kind === 'read' && !input.workspace.stableReadFile) { + // A remote/external workspace without handle support is explicitly a + // weaker backend. A post-read observation at least rejects ordinary + // replacement races; it is not an atomic snapshot and cannot prevent + // an external ABA replacement. + const after = await resolveTarget({ + cwd: call.cwd, + path: target.canonicalPath, + semantics: target.semantics, + ...(call.executionBoundary ? { executionBoundary: call.executionBoundary } : {}), + ...(call.permissionMode ? { permissionMode: call.permissionMode } : {}), + ...(call.abortSignal ? { abortSignal: call.abortSignal } : {}), + }); + if ( + after.canonicalPath !== target.canonicalPath || + identityChanged(target.identity, after.identity) + ) { + throw new StableWriteFailure( + 'path_changed', + 'The filesystem target changed while the external workspace read was running.', + ); + } + } + return result; } const result = await worker.execute({ operation: call.operation, @@ -234,16 +300,12 @@ export function createBoundaryFilesystemExecutor( mode: call.permissionMode ?? 'ask', ...(input.permissionProfile ? { permissionProfile: input.permissionProfile } : {}), ...(call.abortSignal ? { abortSignal: call.abortSignal } : {}), - // The worker client now requires an explicit T0 marker (#3484): a - // mutation carries its captured identity, or 'missing' when T0 saw no - // target; a read never participates in CAS and says so. `operationAccess` - // is the single authority on which kinds are writes (write | apply_patch - // | edit | format_json) — `mutates` is narrower and would silently drop - // the apply_patch identity onto 'unchecked', disabling the queue-window - // CAS on the main editing channel. + // Exact reads and mutations both carry the identity sampled after lease + // admission. Tree searches remain explicitly weaker: a directory inode + // is not a version for all descendants. expectedIdentity: - operationAccess(call.operation.kind) === 'write' - ? (expectedIdentity ?? 'missing') + operationAccess(call.operation.kind) === 'write' || call.operation.kind === 'read' + ? toWorkerExpectedIdentity(target.identity) : 'unchecked', }); if (result.kind === 'read_image') { @@ -262,14 +324,11 @@ export function createBoundaryFilesystemExecutor( ): Promise<{ key: string; canonicalPath: string }> { const worker = workerFor(call.executionBoundary); if (!worker) { - const key = ( - await input.workspace.writeLockKey({ - cwd: call.cwd, - path, - semantics, - }) - ).key; - return { key, canonicalPath: key }; + return await input.workspace.writeLockKey({ + cwd: call.cwd, + path, + semantics, + }); } if (semantics === 'entry') { const resolved = await resolveCanonicalDirectoryEntryTarget(call.cwd, path); @@ -283,59 +342,415 @@ export function createBoundaryFilesystemExecutor( }); return { key: normalized.enforcementPath, canonicalPath: normalized.enforcementPath }; } + async function resolveTarget(inputArg: { + cwd: string; + path: string; + semantics: 'target' | 'entry'; + executionBoundary?: ExecutionBoundary; + permissionMode?: PermissionMode; + abortSignal?: AbortSignal; + }): Promise { + const { key, canonicalPath } = await writeLockTarget( + { + cwd: inputArg.cwd, + ...(inputArg.executionBoundary ? { executionBoundary: inputArg.executionBoundary } : {}), + ...(inputArg.permissionMode ? { permissionMode: inputArg.permissionMode } : {}), + ...(inputArg.abortSignal ? { abortSignal: inputArg.abortSignal } : {}), + }, + inputArg.path, + inputArg.semantics, + ); + // resolveTarget is used both for prepare-time claim resolution and for the + // admission-time execution target. Prepare deliberately discards identity; + // execute samples it again after acquiring the prepared lease. + // Content operations follow the final symlink (stat); create/delete pin the + // directory entry (lstat) so a swapped link is caught against the entry's + // own inode. canonicalPath remains executable while leaseKey is used for + // both the Scheduler claim and coordinator admission. + const identity = await captureFilesystemTargetIdentity( + canonicalPath, + inputArg.semantics === 'target', + ); + return { + canonicalPath, + leaseKey: hostFilesystemLeaseKey(key), + identity: toTargetIdentity(identity, inputArg.semantics), + }; + } + return { + resolveTarget, + run, + }; +} + +/** + * The union of inputs the filesystem authority can prepare: an `execute`-style + * operation (read/write/edit/format_json/glob/grep) or an `apply_patch` op. + * The path is read from `operation.path` in both cases. + */ +export type FilesystemAuthorityInput = FilesystemExecuteInput | FilesystemApplyPatchInput; + +/** + * The process-visible identity captured while the matching lease is held. + */ +function toTargetIdentity( + identity: FilesystemTargetIdentity | undefined, + semantics: 'target' | 'entry', +): TargetIdentity { + if (!identity) return { kind: 'missing' }; + return semantics === 'target' + ? { kind: 'file', dev: identity.dev, ino: identity.ino } + : { kind: 'entry', dev: identity.dev, ino: identity.ino }; +} + +function toExpectedIdentity(identity: TargetIdentity): FilesystemTargetIdentity | undefined { + if (identity.kind === 'missing') return undefined; + return { dev: identity.dev, ino: identity.ino }; +} + +function toWorkerExpectedIdentity(identity: TargetIdentity): FilesystemTargetIdentity | 'missing' { + return toExpectedIdentity(identity) ?? 'missing'; +} + +function filesystemSemantics(target: FilesystemAuthorityInput): 'target' | 'entry' { + if (isApplyPatchInput(target)) { + const operation = target.operation as ApplyPatchOperation; + return operation.type === 'update_file' ? 'target' : 'entry'; + } + return 'target'; +} + +function isApplyPatchInput(target: FilesystemAuthorityInput): boolean { + const operation = (target as FilesystemApplyPatchInput).operation as unknown; + return ( + typeof operation === 'object' && + operation !== null && + 'type' in operation && + (operation as { type?: unknown }).type !== undefined + ); +} + +function isWriteOperation(target: FilesystemAuthorityInput): boolean { + if (isApplyPatchInput(target)) return true; + return operationAccess((target as FilesystemExecuteInput).operation.kind) === 'write'; +} + +function isSearchOperation(target: FilesystemAuthorityInput): boolean { + if (isApplyPatchInput(target)) return false; + const kind = (target as FilesystemExecuteInput).operation.kind; + return kind === 'glob' || kind === 'grep'; +} + +function toBackendOperation(target: FilesystemAuthorityInput): { + path: string; + operation: FilesystemWorkerClientOperation; +} { + if (isApplyPatchInput(target)) { + const operation = (target as FilesystemApplyPatchInput).operation as ApplyPatchOperation; + const backend: FilesystemWorkerClientOperation = + operation.type === 'delete_file' + ? { kind: 'apply_patch', path: operation.path, action: 'delete' } + : { + kind: 'apply_patch', + path: operation.path, + action: operation.type === 'create_file' ? 'create' : 'update', + diff: operation.diff, + }; + return { path: operation.path, operation: backend }; + } + const operation = (target as FilesystemExecuteInput).operation as FilesystemOperation; + return { path: operation.path, operation }; +} + +export function claimFromFilesystemLease(request: FilesystemLeaseRequest): KeyedResourceClaim { + return { + kind: 'keyed', + authority: 'filesystem:workspace', + key: request.key, + mode: request.mode, + scope: request.scope, + }; +} + +function filesystemLeaseFor( + target: FilesystemAuthorityInput, + leaseKey: string, +): FilesystemLeaseRequest { + return { + key: leaseKey, + mode: isWriteOperation(target) ? 'write' : 'read', + scope: isSearchOperation(target) ? 'tree' : 'exact', + }; +} + +function toBackendCall( + target: FilesystemAuthorityInput, + context: Pick, + signal?: AbortSignal, +): FilesystemBackendExecuteInput { + const { operation } = toBackendOperation(target); + return { + operation, + cwd: context.cwd, + ...(context.executionBoundary ? { executionBoundary: context.executionBoundary } : {}), + ...(context.permissionMode ? { permissionMode: context.permissionMode } : {}), + ...((signal ?? context.abortSignal) ? { abortSignal: signal ?? context.abortSignal } : {}), + }; +} + +function replaceOperationPath( + call: FilesystemBackendExecuteInput, + canonicalPath: string, +): FilesystemBackendExecuteInput { + return { ...call, operation: { ...call.operation, path: canonicalPath } }; +} + +interface PreparedFilesystemClaim { + readonly target: FilesystemAuthorityInput; + readonly semantics: 'target' | 'entry'; + readonly canonicalPath: string; + readonly lease: FilesystemLeaseRequest; +} + +export class FilesystemPreparedClaimChangedError extends Error { + override readonly name = 'FilesystemPreparedClaimChangedError'; + readonly code = 'filesystem_prepared_claim_changed'; + + constructor() { + super('The prepared filesystem claim changed before execution; prepare the operation again.'); + } +} + +function assertSamePreparedClaim( + prepared: PreparedFilesystemClaim, + admitted: ResolvedTarget, +): void { + if ( + prepared.canonicalPath !== admitted.canonicalPath || + prepared.lease.key !== admitted.leaseKey + ) { + throw new FilesystemPreparedClaimChangedError(); + } +} + +function resourceArgsFor( + target: FilesystemAuthorityInput, + context: Pick, + signal?: AbortSignal, +): Parameters[0] { + const { path } = toBackendOperation(target); + return { + cwd: context.cwd, + path, + semantics: filesystemSemantics(target), + ...(context.executionBoundary ? { executionBoundary: context.executionBoundary } : {}), + ...(context.permissionMode ? { permissionMode: context.permissionMode } : {}), + ...((signal ?? context.abortSignal) ? { abortSignal: signal ?? context.abortSignal } : {}), + }; +} + +async function prepareFilesystemAccess( + backend: FilesystemBackend, + target: FilesystemAuthorityInput, + context: Pick, +): Promise { + const semantics = filesystemSemantics(target); + const resolved = await backend.resolveTarget(resourceArgsFor(target, context)); return { + target, + semantics, + canonicalPath: resolved.canonicalPath, + lease: filesystemLeaseFor(target, resolved.leaseKey), + }; +} + +function directContext( + input: Pick< + FilesystemExecuteInput, + 'cwd' | 'executionBoundary' | 'permissionMode' | 'abortSignal' + >, +): Pick { + return input; +} + +export interface FilesystemResourceAuthority + extends ResourceAuthority { + preparePatchBatch( + operations: readonly ApplyPatchOperation[], + context: AuthorityContext, + ): Promise>; +} + +export interface FilesystemResourceOwner { + readonly executor: FilesystemExecutor; + readonly authority: FilesystemResourceAuthority; +} + +export function createFilesystemResourceOwner( + input: BoundaryFilesystemExecutorInput, +): FilesystemResourceOwner { + const backend = buildFilesystemBackend(input); + const coordinator = input.filesystemLeaseCoordinator ?? processFilesystemLeases; + const processAdmission = input.processResourceAdmissionCoordinator ?? processResourceAdmissions; + + const executeAccess = async ( + access: PreparedFilesystemClaim, + context: Pick, + signal?: AbortSignal, + ): Promise => { + const abortSignal = signal ?? context.abortSignal; + const executeUnderLease = async (): Promise => + await coordinator.withLease(access.lease, abortSignal, async () => { + const now = await backend.resolveTarget(resourceArgsFor(access.target, context, signal)); + assertSamePreparedClaim(access, now); + const admitted: AdmittedTargetContract = { + canonicalPath: now.canonicalPath, + semantics: access.semantics, + identity: now.identity, + }; + const call = replaceOperationPath( + toBackendCall(access.target, context, signal), + now.canonicalPath, + ); + try { + return await backend.run(call, admitted); + } catch (error) { + throw access.lease.mode === 'write' ? settleMutationFailure(error) : error; + } + }); + return await processAdmission.withShared(abortSignal, executeUnderLease); + }; + + const preparePatchAccesses = async ( + operations: readonly ApplyPatchOperation[], + context: Pick, + ): Promise => + await Promise.all( + operations.map((operation) => + prepareFilesystemAccess(backend, { operation, ...context }, context), + ), + ); + + const executePatchBatch = async ( + accesses: readonly PreparedFilesystemClaim[], + context: Pick, + signal?: AbortSignal, + ): Promise => { + const requests = normalizeFilesystemLeaseRequests(accesses.map((access) => access.lease)); + const abortSignal = signal ?? context.abortSignal; + const executeUnderLeases = async (): Promise => + await coordinator.withLeases(requests, abortSignal, async () => { + // Claim-only preflight must complete before the first mutation. Identity + // is intentionally not retained here: each operation samples the current + // object immediately before its own effect. + for (let index = 0; index < accesses.length; index += 1) { + const access = accesses[index]!; + try { + const now = await backend.resolveTarget( + resourceArgsFor(access.target, context, signal), + ); + assertSamePreparedClaim(access, now); + } catch (error) { + const operation = (access.target as FilesystemApplyPatchInput).operation; + return { + status: 'failed', + applied: [], + failed: { type: operation.type, path: operation.path }, + error: `ApplyPatch preflight failed for ${operation.type} ${operation.path}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + let operationIndex = 0; + const operations = accesses.map( + (access) => (access.target as FilesystemApplyPatchInput).operation, + ); + return await executeApplyPatchOperations( + operations, + async (operation) => { + const index = operationIndex++; + const access = accesses[index]!; + const now = await backend.resolveTarget( + resourceArgsFor(access.target, context, signal), + ); + assertSamePreparedClaim(access, now); + const admitted: AdmittedTargetContract = { + canonicalPath: now.canonicalPath, + semantics: access.semantics, + identity: now.identity, + }; + const call = replaceOperationPath( + toBackendCall({ operation, ...context }, context, signal), + now.canonicalPath, + ); + try { + const result = await backend.run(call, admitted); + if (result.kind !== 'apply_patch') { + throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`); + } + } catch (error) { + throw settleMutationFailure(error); + } + }, + abortSignal, + ); + }); + return await processAdmission.withShared(abortSignal, executeUnderLeases); + }; + + const authority: FilesystemResourceAuthority = { + async prepare(target, context): Promise> { + const access = await prepareFilesystemAccess(backend, target, context); + return oneShotOperation({ + claims: [claimFromFilesystemLease(access.lease)], + execute: async (signal) => await executeAccess(access, context, signal), + }); + }, + async preparePatchBatch(operations, context) { + const accesses = await preparePatchAccesses(operations, context); + const requests = normalizeFilesystemLeaseRequests(accesses.map((access) => access.lease)); + return oneShotOperation({ + claims: requests.map(claimFromFilesystemLease), + execute: async (signal) => await executePatchBatch(accesses, context, signal), + }); + }, + }; + + const executor: FilesystemExecutor = { async execute(call) { - if (operationAccess(call.operation.kind) !== 'write') return await run(call); - // Canonicalisation without any containment check, so a target the policy - // goes on to reject still takes the same lock as its other spellings. The - // key is derived from the same canonicalisation the backend will resolve - // with, or the lock-key space and the resolved-path space drift apart. - const { key, canonicalPath } = await writeLockTarget(call, call.operation.path); - // Capture the target identity at lock acquisition (T0), BEFORE waiting - // for the lock, for BOTH backends — the worker CAS and the local pinned - // read-modify-write compare against this inode. Content operations follow - // the final symlink (stat); apply_patch create/delete use 'entry' - // semantics but execute() only handles write/edit/format_json here. - const expectedIdentity = await captureIdentityAtLockAcquisition(canonicalPath, true); - try { - return await withFileWriteLock(key, () => run(call, expectedIdentity)); - } catch (error) { - throw settleMutationFailure(error); - } + const context = directContext(call); + const access = await prepareFilesystemAccess(backend, call, context); + return await executeAccess(access, context, call.abortSignal); }, async applyPatch(call) { - const { operation, ...common } = call; - const semantics = operation.type === 'update_file' ? 'target' : 'entry'; - const { key, canonicalPath } = await writeLockTarget(common, operation.path, semantics); - // Capture identity at T0 (before the lock wait), for both backends. - // update_file follows the target (stat); create/delete pin the directory - // entry (lstat). - const expectedIdentity = await captureIdentityAtLockAcquisition( - canonicalPath, - semantics === 'target', - ); - try { - return await withFileWriteLock(key, async () => { - const backendOperation: FilesystemWorkerClientOperation = - operation.type === 'delete_file' - ? { kind: 'apply_patch', path: operation.path, action: 'delete' } - : { - kind: 'apply_patch', - path: operation.path, - action: operation.type === 'create_file' ? 'create' : 'update', - diff: operation.diff, - }; - const result = await run({ ...common, operation: backendOperation }, expectedIdentity); - if (result.kind !== 'apply_patch') { - throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`); - } - return { status: 'completed' }; - }); - } catch (error) { - throw settleMutationFailure(error); + const context = directContext(call); + const access = await prepareFilesystemAccess(backend, call, context); + const result = await executeAccess(access, context, call.abortSignal); + if (result.kind !== 'apply_patch') { + throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`); } + return { status: 'completed' }; + }, + async applyPatchBatch(call) { + const context = directContext(call); + const accesses = await preparePatchAccesses(call.operations, context); + return await executePatchBatch(accesses, context, call.abortSignal); }, }; + + return { executor, authority }; +} + +export function createBoundaryFilesystemExecutor( + input: BoundaryFilesystemExecutorInput, +): FilesystemExecutor { + return createFilesystemResourceOwner(input).executor; +} + +export function createFilesystemResourceAuthority( + input: BoundaryFilesystemExecutorInput, +): FilesystemResourceAuthority { + return createFilesystemResourceOwner(input).authority; } /** @@ -350,7 +765,7 @@ function settleMutationFailure(error: unknown): unknown { if (error.code === 'outcome_unknown') { return new ToolOutcomeUnknownError(error.message, { cause: error }); } - return new Error(error.message, { cause: error }); + return error; } if (classifyFailedMutationOutcome(error) === 'unknown') { return new ToolOutcomeUnknownError( @@ -365,7 +780,7 @@ interface WorkspaceFilesystemBackend { execute( input: FilesystemBackendExecuteInput, scope: WorkspacePathScope, - expectedIdentity?: FilesystemTargetIdentity, + target: AdmittedTargetContract, ): Promise; } @@ -378,9 +793,33 @@ function createWorkspaceFilesystemExecutor( workspace: FilesystemWorkspaceExecutor, ): WorkspaceFilesystemBackend { return { - async execute({ operation, cwd, abortSignal }, scope, expectedIdentity) { + async execute({ operation, cwd, abortSignal }, scope, target) { switch (operation.kind) { case 'read': { + if (workspace.stableReadFile) { + // Preserve the workspace executor's containment policy, then read + // the already-admitted canonical path through the pinned handle. + // The resolver result is intentionally not used as a new target. + await workspace.resolveExistingPath({ + cwd, + path: target.canonicalPath, + label: 'Read', + scope, + }); + const result = await workspace.stableReadFile({ + cwd, + path: target.canonicalPath, + expectedIdentity: toWorkerExpectedIdentity(target.identity), + ...(operation.offset !== undefined ? { offset: operation.offset } : {}), + ...(operation.limit !== undefined ? { limit: operation.limit } : {}), + }); + if ('bytes' in result) { + return { kind: 'read_image', bytes: result.bytes, mimeType: result.mimeType }; + } + return { kind: 'read', content: result.content }; + } + // External workspace fallback: the provider may not expose a handle + // or CAS primitive. This is a weaker path-based read contract. const { path } = await workspace.resolveExistingPath({ cwd, path: operation.path, @@ -406,14 +845,14 @@ function createWorkspaceFilesystemExecutor( scope, }); if (workspace.readModifyWrite) { - // Pinned RMW (#2600): open once, validate the T0 identity on the + // Pinned RMW (#2600): open once, validate the admitted identity on the // descriptor, write through it. previous feeds the diff below. const result = await workspace.readModifyWrite({ cwd, path, label: 'Write', scope, - approvedIdentity: expectedIdentity, + approvedIdentity: toExpectedIdentity(target.identity), transform: () => operation.content, }); const diff = @@ -475,7 +914,7 @@ function createWorkspaceFilesystemExecutor( await workspace.readModifyWrite({ ...common, path, - approvedIdentity: expectedIdentity, + approvedIdentity: toExpectedIdentity(target.identity), transform: (ctx) => applyUpdateToContent(ctx.content ?? '', operation.diff), }); return { kind: 'apply_patch', ok: true, path }; @@ -485,7 +924,9 @@ function createWorkspaceFilesystemExecutor( ? { ...common, action: 'delete' as const, - ...(expectedIdentity ? { approvedIdentity: expectedIdentity } : {}), + ...(toExpectedIdentity(target.identity) + ? { approvedIdentity: toExpectedIdentity(target.identity) } + : {}), } : { ...common, action: operation.action, diff: operation.diff }, ); @@ -507,7 +948,7 @@ function createWorkspaceFilesystemExecutor( path, label: 'Edit', scope, - approvedIdentity: expectedIdentity, + approvedIdentity: toExpectedIdentity(target.identity), transform: (ctx) => { originalContent = ctx.content ?? ''; edited = computeEditedSource( @@ -570,7 +1011,7 @@ function createWorkspaceFilesystemExecutor( path, label: 'FormatJson', scope, - approvedIdentity: expectedIdentity, + approvedIdentity: toExpectedIdentity(target.identity), transform: (ctx) => { original = ctx.content ?? ''; try { diff --git a/packages/runtime/src/filesystem-lease-coordinator.ts b/packages/runtime/src/filesystem-lease-coordinator.ts new file mode 100644 index 0000000000..fe0c088dc0 --- /dev/null +++ b/packages/runtime/src/filesystem-lease-coordinator.ts @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { scopedKeysOverlap } from './preparation/claims.js'; + +export type FilesystemLeaseMode = 'read' | 'write'; +export type FilesystemLeaseScope = 'exact' | 'tree'; + +export interface FilesystemLeaseRequest { + /** Platform-normalized coordination key. This is not a backend path. */ + readonly key: string; + readonly mode: FilesystemLeaseMode; + readonly scope: FilesystemLeaseScope; +} + +export interface FilesystemLeaseCoordinator { + withLease( + request: FilesystemLeaseRequest, + signal: AbortSignal | undefined, + effect: () => Promise, + ): Promise; + + withLeases( + requests: readonly FilesystemLeaseRequest[], + signal: AbortSignal | undefined, + effect: () => Promise, + ): Promise; +} + +type WaiterState = 'queued' | 'active' | 'settled'; + +interface Waiter { + readonly sequence: number; + readonly requests: readonly FilesystemLeaseRequest[]; + readonly signal?: AbortSignal; + readonly effect: () => Promise; + state: WaiterState; + resolve(value: Result): void; + reject(error: unknown): void; + removeAbortListener?: () => void; +} + +export function filesystemLeaseRequestsConflict( + a: FilesystemLeaseRequest, + b: FilesystemLeaseRequest, +): boolean { + if (a.mode !== 'write' && b.mode !== 'write') return false; + return scopedKeysOverlap(a.key, a.scope, b.key, b.scope); +} + +export function filesystemLeaseRequestSetsConflict( + a: readonly FilesystemLeaseRequest[], + b: readonly FilesystemLeaseRequest[], +): boolean { + return a.some((left) => b.some((right) => filesystemLeaseRequestsConflict(left, right))); +} + +export function normalizeFilesystemLeaseRequests( + requests: readonly FilesystemLeaseRequest[], +): readonly FilesystemLeaseRequest[] { + const unique = new Map(); + for (const request of requests) { + if (request.key.length === 0) throw new Error('Filesystem lease key must not be empty.'); + const identity = `${request.key}\u0000${request.mode}\u0000${request.scope}`; + if (!unique.has(identity)) unique.set(identity, Object.freeze({ ...request })); + } + return Object.freeze( + [...unique.values()].sort( + (a, b) => + compareStrings(a.key, b.key) || + compareStrings(a.mode, b.mode) || + compareStrings(a.scope, b.scope), + ), + ); +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'); +} + +export function createFilesystemLeaseCoordinator(): FilesystemLeaseCoordinator { + let nextSequence = 0; + const queued: Waiter[] = []; + const active = new Set>(); + + const removeQueued = (waiter: Waiter): void => { + const index = queued.indexOf(waiter); + if (index >= 0) queued.splice(index, 1); + }; + + const drain = (): void => { + for (const candidate of [...queued]) { + if (candidate.state !== 'queued') continue; + const conflictsWithActive = [...active].some((current) => + filesystemLeaseRequestSetsConflict(current.requests, candidate.requests), + ); + if (conflictsWithActive) continue; + const conflictsWithEarlier = queued.some( + (earlier) => + earlier !== candidate && + earlier.state === 'queued' && + earlier.sequence < candidate.sequence && + filesystemLeaseRequestSetsConflict(earlier.requests, candidate.requests), + ); + if (conflictsWithEarlier) continue; + + removeQueued(candidate); + if (candidate.signal?.aborted) { + candidate.removeAbortListener?.(); + candidate.removeAbortListener = undefined; + candidate.state = 'settled'; + candidate.reject(abortReason(candidate.signal)); + continue; + } + + candidate.state = 'active'; + active.add(candidate); + candidate.removeAbortListener?.(); + candidate.removeAbortListener = undefined; + void Promise.resolve() + .then(candidate.effect) + .then( + (value) => settle(candidate, { status: 'fulfilled', value }), + (reason: unknown) => settle(candidate, { status: 'rejected', reason }), + ); + } + }; + + const settle = (waiter: Waiter, outcome: PromiseSettledResult): void => { + active.delete(waiter as Waiter); + waiter.removeAbortListener?.(); + waiter.removeAbortListener = undefined; + waiter.state = 'settled'; + drain(); + if (outcome.status === 'fulfilled') waiter.resolve(outcome.value); + else waiter.reject(outcome.reason); + }; + + const withLeases = ( + requests: readonly FilesystemLeaseRequest[], + signal: AbortSignal | undefined, + effect: () => Promise, + ): Promise => { + signal?.throwIfAborted(); + const normalized = normalizeFilesystemLeaseRequests(requests); + if (normalized.length === 0) return Promise.resolve().then(effect); + + return new Promise((resolve, reject) => { + const waiter: Waiter = { + sequence: nextSequence++, + requests: normalized, + ...(signal ? { signal } : {}), + effect, + state: 'queued', + resolve, + reject, + }; + if (signal) { + const onAbort = (): void => { + if (waiter.state !== 'queued') return; + removeQueued(waiter as Waiter); + waiter.removeAbortListener?.(); + waiter.removeAbortListener = undefined; + waiter.state = 'settled'; + drain(); + waiter.reject(abortReason(signal)); + }; + signal.addEventListener('abort', onAbort, { once: true }); + waiter.removeAbortListener = () => signal.removeEventListener('abort', onAbort); + } + queued.push(waiter as Waiter); + drain(); + }); + }; + + return { + withLease(request, signal, effect) { + return withLeases([request], signal, effect); + }, + withLeases, + }; +} + +/** Default namespace shared by every builtin filesystem owner in this process. */ +export const processFilesystemLeases = createFilesystemLeaseCoordinator(); diff --git a/packages/runtime/src/filesystem-lease-key.ts b/packages/runtime/src/filesystem-lease-key.ts new file mode 100644 index 0000000000..8f7af52bf3 --- /dev/null +++ b/packages/runtime/src/filesystem-lease-key.ts @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Map a backend-executable canonical host path into the coordinator key space. + * This never rewrites the path sent to the backend. + */ +export function filesystemLeaseKeyForPlatform( + canonicalPath: string, + platform: NodeJS.Platform, +): string { + return platform === 'win32' ? canonicalPath.toUpperCase() : canonicalPath; +} + +/** The host filesystem's process-wide coordination key. */ +export function hostFilesystemLeaseKey(canonicalPath: string): string { + return filesystemLeaseKeyForPlatform(canonicalPath, process.platform); +} diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index a370a512cf..c1ec56e627 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -71,18 +71,20 @@ export interface FilesystemWorkerClientInput { } /** - * What the caller observed about the operation target at lock acquisition - * (T0). Required, so every caller must decide explicitly which CAS contract it + * What the caller observed about the operation target after filesystem lease + * admission. Required for mutations; exact reads from the filesystem authority + * also provide it so the worker can pin the admitted object. Every caller decides + * explicitly which CAS contract it * is participating in — an absent field is no longer a silently accepted "no * CAS" that a queue window can slip through (#3484). * - * - `{ dev, ino }`: T0 observed an existing target; the worker compare-and- - * swaps this against the on-disk inode at T1. - * - `'missing'`: T0 observed no target (a create). If the target exists by T1, + * - `{ dev, ino }`: admission observed an existing target; the worker validates + * the opened descriptor against it. + * - `'missing'`: admission observed no target (a create). If the target exists, * something created it while this call waited — writing would clobber * content this call never saw, so the operation fails with `path_changed`. - * - `'unchecked'`: the caller does not participate in CAS (no T0 snapshot, - * e.g. a verification script or a read). Writes proceed without an identity + * - `'unchecked'`: the caller does not participate in CAS (no admitted snapshot, + * e.g. a verification script or tree search). Writes proceed without an identity * check; use deliberately, never as a default for mutations. */ export type FilesystemWorkerExpectedIdentity = @@ -99,13 +101,13 @@ export interface FilesystemWorkerExecuteInput { permissionProfile?: PermissionProfile; abortSignal?: AbortSignal; /** - * The caller's T0 observation, see `FilesystemWorkerExpectedIdentity`. + * The caller's admission-time observation, see `FilesystemWorkerExpectedIdentity`. * * REQUIRED for write operations: the client throws at runtime when a write * arrives without one, so a JavaScript caller (which TypeScript cannot - * guard) fails loudly instead of silently skipping the queue-window CAS. - * Reads never participate in CAS: the client sends 'unchecked' for them - * automatically, so read callers have no way to get this wrong. + * guard) fails loudly instead of silently skipping the execution-window CAS. + * Reads without an authority-provided identity remain `unchecked` for backwards + * compatibility; normal exact Read execution always supplies one. */ expectedIdentity?: FilesystemWorkerExpectedIdentity; } @@ -217,14 +219,11 @@ export class FilesystemWorkerClient { if (!parsedOperation.success) throw clientError('invalid_operation', 'validation', requestId); const access = operationAccess(parsedOperation.data.kind); - // Reads never participate in CAS: the client sends 'unchecked' for them - // automatically, so read callers (including plain-JavaScript verifiers - // that bypass TypeScript) have no way to get the identity wrong. - // Writes require an explicit T0 state, enforced at runtime: a caller that + // Writes require an explicit admission state, enforced at runtime: a caller that // omits it fails loudly here instead of silently skipping the - // queue-window CAS (#3487, maintainer review). - const writeIdentity = access === 'write' ? input.expectedIdentity : 'unchecked'; - if (access === 'write' && writeIdentity === undefined) { + // execution-window CAS (#3487, maintainer review). + const admittedIdentity = input.expectedIdentity ?? 'unchecked'; + if (access === 'write' && input.expectedIdentity === undefined) { throw clientError( 'invalid_request', 'validation', @@ -252,34 +251,26 @@ export class FilesystemWorkerClient { ).catch(() => { throw clientError('invalid_operation', 'validation', requestId); }); - // The identity was captured by the caller at lock acquisition (T0) and - // passed in as expectedIdentity. Do NOT re-derive it here: re-deriving at - // this point (after the lock is held) would sample the post-queue inode, - // making the CAS self-fulfilling and re-opening the queue window. - // - // Missing↔existing transitions while queued are reconciled here, against - // the T1 reality the target normaliser just derived: - // - T0 existing (identity present) but T1 missing: the target was removed - // while this call waited — typically a cooperative Maka delete that ran - // first under the same write lock. Drop the stale identity and let the - // mutation proceed as a fresh exclusive create ("delete then rewrite" - // stays a clean apply; a rename-swap is NOT this case — it leaves an - // existing inode and is caught by the identity comparison instead). - // - T0 missing ('missing') but T1 existing: the target was created while - // this call waited. Writing would clobber content this call never saw, - // so fail with a meaningful path_changed (never invalid_request). - // - 'unchecked': the caller does not participate in CAS. The target may - // be present at T1 without this being a race — the caller simply has no - // T0 snapshot, so nothing can be compared (#3484). - const targetExistsAtT1 = target.targetType !== 'missing'; - const identity = - targetExistsAtT1 && typeof writeIdentity === 'object' ? writeIdentity : undefined; - if (targetExistsAtT1 && access === 'write' && writeIdentity === 'missing') { + // The identity was captured after authority admission. Do NOT re-derive it + // here: any missing/existing transition between admission and client + // normalisation is already an execution-window race and must fail closed. + // - 'unchecked': the caller does not participate in CAS, so nothing can be + // compared (#3484). + const targetExistsNow = target.targetType !== 'missing'; + if (targetExistsNow && admittedIdentity === 'missing') { throw clientError( 'path_changed', 'validation', requestId, - 'The target was created while this call waited for the lock; re-read before writing.', + 'The target appeared after filesystem admission; the operation was not started.', + ); + } + if (!targetExistsNow && typeof admittedIdentity === 'object') { + throw clientError( + 'path_changed', + 'validation', + requestId, + 'The target disappeared after filesystem admission; the operation was not started.', ); } const compiled = @@ -361,11 +352,8 @@ export class FilesystemWorkerClient { access, scope: target.scope, targetType: target.targetType, - // The execution-time identity contract. A concrete identity is only - // carried when the target still exists at T1; a target that vanished - // while queued (or was never there) is 'missing'; reads always say - // 'unchecked' (the client generates it, callers cannot get it wrong). - identity: typeof writeIdentity === 'object' ? (identity ?? 'missing') : writeIdentity, + // The execution-time identity contract captured after lease admission. + identity: admittedIdentity, }, } as const; const requestJson = JSON.stringify(request); diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 40f956aeeb..3c06a70c39 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -40,10 +40,10 @@ import { StableWriteFailure, writeThroughHandle, } from '../file-stable-write.js'; +import { readStableTarget } from '../file-stable-read.js'; import { isSupportedImagePath, readWorkspaceImage } from '../image-file.js'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION, - operationAccess, operationUsesDirectoryEntry, type FilesystemWorkerErrorCode, type FilesystemWorkerOperation, @@ -97,7 +97,6 @@ export async function executeFilesystemWorkerRequest( request.operation.path, request.expectedTarget, operationUsesDirectoryEntry(request.operation), - operationAccess(request.operation.kind), ); return { version: FILESYSTEM_WORKER_PROTOCOL_VERSION, @@ -136,6 +135,22 @@ export async function executeFilesystemOperation( 'read', operationBoundary, ); + if (expectedTarget && expectedTarget.identity !== 'unchecked') { + const result = await readStableTarget({ + path, + expectedIdentity: expectedTarget.identity, + ...(operation.offset !== undefined ? { offset: operation.offset } : {}), + ...(operation.limit !== undefined ? { limit: operation.limit } : {}), + }); + if ('bytes' in result) { + return { + kind: 'read_image', + base64: Buffer.from(result.bytes).toString('base64'), + mimeType: result.mimeType, + }; + } + return { kind: 'read', content: result.content }; + } if (isSupportedImagePath(path)) { try { const image = await readWorkspaceImage(path); @@ -492,7 +507,6 @@ async function assertTargetUnchanged( path: string, expected: FilesystemWorkerTarget, noFollowFinalSymlink = false, - access: 'read' | 'write' = 'read', ): Promise { const enforcementPath = noFollowFinalSymlink ? (await resolveCanonicalDirectoryEntryTarget(cwd, path)).path @@ -506,22 +520,19 @@ async function assertTargetUnchanged( 'The approved filesystem target changed before execution.', ); } - // Compare the on-disk identity against the one captured at authorisation - // time. This is the load-bearing check for the queue window: a path swapped - // while the call waited for the lock has a different inode even when its - // canonical path and type still match. + // Compare the on-disk identity against the admission-time observation. This + // is an early rejection; exact reads and mutations additionally validate the + // opened descriptor so a later pathname swap cannot redirect their effect. // // The wire carries one required three-state identity contract (#3484): // - { dev, ino }: CAS against the on-disk inode. - // - 'missing': T0 saw no target but T1 does — something created it while - // this call waited. Writing would clobber content the caller never saw. + // - 'missing': admission saw no target but one exists now. // - 'unchecked': the caller deliberately does not participate in CAS. - // Reads never mutate and are exempt either way. - if (access === 'write' && expected.targetType !== 'missing') { + if (expected.targetType !== 'missing') { if (expected.identity === 'missing') { throw operationError( 'path_changed', - 'The target was created while this call waited for the lock; re-read before writing.', + 'The target appeared after filesystem admission; the operation was not started.', ); } if (typeof expected.identity === 'object') { diff --git a/packages/runtime/src/filesystem-worker/protocol.ts b/packages/runtime/src/filesystem-worker/protocol.ts index f26b75462c..d5ffcf9d38 100644 --- a/packages/runtime/src/filesystem-worker/protocol.ts +++ b/packages/runtime/src/filesystem-worker/protocol.ts @@ -22,7 +22,7 @@ import { validateSandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; // v6 adds the captured target identity (opaque decimal-string dev/ino) to // FilesystemWorkerTarget, so the worker can compare-and-swap against the -// inode that was authorised at lock acquisition instead of only the path +// inode that was observed after filesystem lease admission instead of only the path // string. The identity is carried as strings because bigint cannot cross the // JSON protocol boundary. export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 7 as const; @@ -82,11 +82,11 @@ export const FilesystemWorkerTargetSchema = z scope: z.enum(['exact', 'subtree']), targetType: z.enum(['file', 'directory', 'symlink', 'other', 'missing']), // The execution-time identity contract, one required field (no separate - // T0 marker — a single three-state shape mirrors the client input, so an + // marker — a single three-state shape mirrors the client input, so an // illegal combination cannot be expressed on the wire): - // - { dev, ino }: the T0 identity the worker must CAS against at T1. - // - 'missing': T0 saw no target; a target present at execution time was - // created while the call waited and must fail. + // - { dev, ino }: the admission identity the worker must validate. + // - 'missing': admission saw no target; a target present at execution time + // appeared in the effect window and must fail. // - 'unchecked': the caller does not participate in CAS; the write // proceeds without an identity comparison. identity: FilesystemTargetIdentitySchema.or(z.literal('missing')).or(z.literal('unchecked')), diff --git a/packages/runtime/src/preparation/claims.ts b/packages/runtime/src/preparation/claims.ts new file mode 100644 index 0000000000..d95805b604 --- /dev/null +++ b/packages/runtime/src/preparation/claims.ts @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// packages/runtime/src/preparation/claims.ts +// Domain-agnostic conflict predicate over `ResourceClaim`. This is the ONLY +// comparison the Scheduler performs — it compares canonical keys, never +// resolves an identity. Two operations conflict iff they would touch the same +// canonical resource in a way that requires ordering. + +import type { KeyedResourceClaim, ResourceClaim } from './types.js'; + +/** + * True when any claim on the left conflicts with any claim on the right. This + * is the pure function the Scheduler uses to derive the partial order. + */ +export function claimsConflict( + left: readonly ResourceClaim[], + right: readonly ResourceClaim[], +): boolean { + return left.some((a) => right.some((b) => resourceClaimsConflict(a, b))); +} + +export function resourceClaimsConflict(a: ResourceClaim, b: ResourceClaim): boolean { + // Batch-local description only: the corresponding PreparedOperation acquires + // the real process-wide exclusive barrier against participating authorities. + if (a.kind === 'all' || b.kind === 'all') return true; + + // Coarse claims only interact with another coarse claim on the same key. + // (Cross-coarse↔keyed interaction is intentionally left as a future + // extension point: no tool in this batch emits `coarse`.) + if (a.kind === 'coarse' || b.kind === 'coarse') { + return a.kind === 'coarse' && b.kind === 'coarse' && a.key === b.key; + } + + if (a.kind !== b.kind) return false; + + if (a.kind === 'keyed' && b.kind === 'keyed') return keyedClaimsConflict(a, b); + + // Capacity is a permit pool, not a mutex. Per Option B §7.1 it must NOT be + // folded into the conflict graph — that would turn backpressure into + // serialization. Shared pools are enforced by an independent semaphore. + if (a.kind === 'capacity' && b.kind === 'capacity') return false; + + return false; +} + +function keyedClaimsConflict(a: KeyedResourceClaim, b: KeyedResourceClaim): boolean { + if (a.authority !== b.authority) return false; + // Two reads never conflict, whether they are exact or tree-scoped. + const aWrites = a.mode === 'write' || a.mode === 'exclusive'; + const bWrites = b.mode === 'write' || b.mode === 'exclusive'; + if (!aWrites && !bWrites) return false; + return keyedKeysOverlap(a, b); +} + +function keyedKeysOverlap(a: KeyedResourceClaim, b: KeyedResourceClaim): boolean { + return scopedKeysOverlap(a.key, a.scope ?? 'exact', b.key, b.scope ?? 'exact'); +} + +export function scopedKeysOverlap( + aKey: string, + aScope: 'exact' | 'tree', + bKey: string, + bScope: 'exact' | 'tree', +): boolean { + if (aKey === bKey) return true; + if (aScope === 'tree' && containsPath(aKey, bKey)) return true; + if (bScope === 'tree' && containsPath(bKey, aKey)) return true; + return false; +} + +export function containsPath(parent: string, candidate: string): boolean { + if (parent === candidate) return true; + if (parent.length === 0 || !candidate.startsWith(parent)) return false; + // Claim keys are canonical strings produced by their authority. Filesystem + // authorities retain the host separator, so a domain-agnostic Scheduler must + // recognise both POSIX and Windows boundaries without rewriting the lock key. + if (parent.endsWith('/') || parent.endsWith('\\')) return true; + const boundary = candidate[parent.length]; + return boundary === '/' || boundary === '\\'; +} diff --git a/packages/runtime/src/preparation/default-tool-authorities.ts b/packages/runtime/src/preparation/default-tool-authorities.ts new file mode 100644 index 0000000000..3fc3c05c51 --- /dev/null +++ b/packages/runtime/src/preparation/default-tool-authorities.ts @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { allResourceAuthority, noneResourceAuthority } from './placeholder-authorities.js'; +import { + processResourceAdmissions, + type ProcessResourceAdmissionCoordinator, +} from '../process-resource-admission.js'; +import type { ToolAuthorityRegistration } from './tool-authority-registry.js'; + +/** Stable Kimi-policy decisions for tools outside the filesystem domain. */ +export const EXPLICIT_NONE_TOOL_AUTHORITY_IDS = Object.freeze([ + 'WebSearch', + 'WebFetch', + // Code Mode is an orchestration container. Its nested leaf calls prepare and + // acquire their own authorities; the container itself owns only cell capacity. + 'exec', + 'agent_spawn', + 'agent_list', + 'agent_output', + 'view_agent_graph', + 'agent_swarm_status', + // Turn-scoped immutable inventory + in-memory ranking: no resource lease. + 'SkillSearch', + // These implementations already own their cross-call correctness through + // domain admission lanes, transactions, leases, or state-machine gates. + // Keep them outside Scheduler ordering until their precise ResourceAuthority + // adapters are completed; execute still enters the real implementation. + 'StopBackgroundTask', + 'WriteStdin', + 'todo_read', + 'todo_write', + 'SearchHistory', + 'ReadHistory', + 'ScheduledTask', + 'GoalSet', + 'GoalClear', + 'GoalStatus', + 'GoalPause', + 'GoalResume', + 'SubmitPlan', + 'update_plan', + 'cancel_plan', +] as const); + +export const EXPLICIT_ALL_TOOL_AUTHORITY_IDS = Object.freeze([ + 'Bash', + 'update_agent_graph', + 'yield_agent_graph', + 'AskUserQuestion', + 'request_sandbox_boundary', + 'Skill', + 'tool_search', + 'maka_tool_search', + // The implementation queues by Session, but two Sessions can still drive + // one physical window. Keep the global fallback until host/window identity + // is resolved before execution. + 'maka_computer', +] as const); + +/** + * Static policy registrations are composed once into the process registry. + * Dynamic and newly introduced tools remain safe through registry-miss all(). + */ +export function defaultToolAuthorityRegistrations( + processAdmission: ProcessResourceAdmissionCoordinator = processResourceAdmissions, +): readonly ToolAuthorityRegistration[] { + return Object.freeze([ + ...EXPLICIT_NONE_TOOL_AUTHORITY_IDS.map((toolId) => [toolId, noneResourceAuthority()] as const), + ...EXPLICIT_ALL_TOOL_AUTHORITY_IDS.map( + (toolId) => [toolId, allResourceAuthority(processAdmission)] as const, + ), + ]); +} diff --git a/packages/runtime/src/preparation/domain-authority-contracts.ts b/packages/runtime/src/preparation/domain-authority-contracts.ts new file mode 100644 index 0000000000..1253dda6ba --- /dev/null +++ b/packages/runtime/src/preparation/domain-authority-contracts.ts @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ResourceAuthority } from './types.js'; + +export type DomainAccessMode = 'read' | 'write' | 'exclusive'; + +export interface SessionDomainOperation { + readonly operation: Operation; +} + +export interface SessionDomainAuthority, Result> + extends ResourceAuthority {} + +export type SessionTodoResourceOperation = + | SessionDomainOperation<'read'> + | (SessionDomainOperation<'replace'> & { readonly items: unknown }); + +export type GoalResourceOperation = + | SessionDomainOperation<'status'> + | (SessionDomainOperation<'set'> & { readonly condition: string }) + | SessionDomainOperation<'clear'> + | SessionDomainOperation<'pause'> + | SessionDomainOperation<'resume'>; + +export type PlanResourceOperation = + | SessionDomainOperation<'submit'> + | (SessionDomainOperation<'update'> & { readonly executionId: string }) + | (SessionDomainOperation<'cancel'> & { readonly executionId: string }); + +export type ShellRunResourceOperation = + | (SessionDomainOperation<'stop'> & { readonly ref: string }) + | (SessionDomainOperation<'write'> & { readonly ref: string }); + +export type HistoryResourceOperation = + | SessionDomainOperation<'search'> + | (SessionDomainOperation<'read'> & { readonly sessionId: string }); + +export type ScheduledTaskResourceOperation = + | SessionDomainOperation<'list'> + | SessionDomainOperation<'create'> + | (SessionDomainOperation<'pause' | 'resume' | 'delete'> & { readonly taskId: string }); + +export type SkillCatalogResourceOperation = SessionDomainOperation<'search'>; + +export type ComputerResourceOperation = SessionDomainOperation<'invoke'>; + +export type SessionTodoResourceAuthority = SessionDomainAuthority< + SessionTodoResourceOperation, + Result +>; + +export type GoalResourceAuthority = SessionDomainAuthority< + GoalResourceOperation, + Result +>; + +export type PlanResourceAuthority = SessionDomainAuthority< + PlanResourceOperation, + Result +>; + +export type ShellRunResourceAuthority = SessionDomainAuthority< + ShellRunResourceOperation, + Result +>; + +export type HistoryResourceAuthority = ResourceAuthority< + HistoryResourceOperation, + Result +>; + +export type ScheduledTaskResourceAuthority = ResourceAuthority< + ScheduledTaskResourceOperation, + Result +>; + +export type SkillCatalogResourceAuthority = ResourceAuthority< + SkillCatalogResourceOperation, + Result +>; + +export type ComputerResourceAuthority = SessionDomainAuthority< + ComputerResourceOperation, + Result +>; + +/** + * Common identities for precise non-filesystem authorities. The contracts are + * ready for domain adapters; until those adapters are completed, tools with an + * internal correctness boundary use none() and unsafe shared domains use all(). + */ +export type PreciseDomainAuthorityKind = + | 'session-todo' + | 'goal' + | 'plan' + | 'shell-run' + | 'history' + | 'scheduled-task' + | 'skill-catalog' + | 'computer' + | 'deep-research' + | 'memory' + | 'terminal-background'; + +export interface PreciseDomainResourceIdentity { + readonly authority: PreciseDomainAuthorityKind; + /** Stable domain identity such as sessionId, taskId, graphId, or terminalId. */ + readonly key: string; +} + +export interface PreciseDomainAuthority + extends ResourceAuthority { + readonly domain: PreciseDomainAuthorityKind; +} + +export interface SessionTodoAuthority + extends PreciseDomainAuthority { + readonly domain: 'session-todo'; +} + +export interface GoalAuthority + extends PreciseDomainAuthority { + readonly domain: 'goal'; +} + +export interface PlanAuthority + extends PreciseDomainAuthority { + readonly domain: 'plan'; +} + +export interface ShellRunAuthority + extends PreciseDomainAuthority { + readonly domain: 'shell-run'; +} + +export interface HistoryAuthority + extends PreciseDomainAuthority { + readonly domain: 'history'; +} + +export interface ScheduledTaskAuthority + extends PreciseDomainAuthority { + readonly domain: 'scheduled-task'; +} + +export interface SkillCatalogAuthority + extends PreciseDomainAuthority { + readonly domain: 'skill-catalog'; +} + +export interface ComputerAuthority + extends PreciseDomainAuthority { + readonly domain: 'computer'; +} + +export interface DeepResearchAuthority + extends PreciseDomainAuthority { + readonly domain: 'deep-research'; +} + +export interface MemoryAuthority + extends PreciseDomainAuthority { + readonly domain: 'memory'; +} + +export interface TerminalBackgroundAuthority + extends PreciseDomainAuthority { + readonly domain: 'terminal-background'; +} diff --git a/packages/runtime/src/preparation/one-shot-operation.ts b/packages/runtime/src/preparation/one-shot-operation.ts new file mode 100644 index 0000000000..3ac3dc65e6 --- /dev/null +++ b/packages/runtime/src/preparation/one-shot-operation.ts @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PreparedOperation } from './types.js'; + +const ONE_SHOT_OPERATION = Symbol('maka.one-shot-prepared-operation'); + +type ConsumedState = 'running' | 'settled'; + +type BrandedPreparedOperation = PreparedOperation & { + readonly [ONE_SHOT_OPERATION]: true; +}; + +/** A duplicate execute attempt is an invariant violation, not an effect retry. */ +export class PreparedOperationAlreadyExecutedError extends Error { + override readonly name = 'PreparedOperationAlreadyExecutedError'; + + constructor(readonly state: ConsumedState) { + super(`PreparedOperation has already been executed (${state})`); + } +} + +/** + * Decorate a PreparedOperation with an atomic one-shot state transition. + * + * The operation is consumed before its effect is invoked. A synchronous throw, + * asynchronous rejection, or abort therefore never makes the same prepared + * capability reusable; retrying requires a fresh prepare call. + */ +export function oneShotOperation( + operation: PreparedOperation, +): PreparedOperation { + if (isOneShotOperation(operation)) return operation; + + let state: 'ready' | ConsumedState = 'ready'; + const wrapped: PreparedOperation = { + claims: operation.claims, + execute(signal, fallbackEffect, executionContext) { + if (state !== 'ready') { + return Promise.reject(new PreparedOperationAlreadyExecutedError(state)); + } + + // Move to running before invoking user/domain code so two synchronous + // callers cannot both pass the ready check. + state = 'running'; + let execution: Promise; + try { + execution = Promise.resolve(operation.execute(signal, fallbackEffect, executionContext)); + } catch (error) { + execution = Promise.reject(error); + } + return execution.finally(() => { + state = 'settled'; + }); + }, + }; + Object.defineProperty(wrapped, ONE_SHOT_OPERATION, { + value: true, + enumerable: false, + }); + return wrapped; +} + +function isOneShotOperation( + operation: PreparedOperation, +): operation is BrandedPreparedOperation { + return (operation as Partial>)[ONE_SHOT_OPERATION] === true; +} diff --git a/packages/runtime/src/preparation/placeholder-authorities.ts b/packages/runtime/src/preparation/placeholder-authorities.ts new file mode 100644 index 0000000000..2898fb3cbd --- /dev/null +++ b/packages/runtime/src/preparation/placeholder-authorities.ts @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// packages/runtime/src/preparation/placeholder-authorities.ts +// Placeholder authorities for domains that do not yet own a ResourceAuthority. +// +// - `none()`: claims=[] -> the Scheduler neither blocks this task nor is blocked +// by it -> it starts immediately. It must be selected explicitly for tools +// known not to occupy a modelled resource, or for synthetic/no-effect calls. +// - `all()`: claims=[{kind:'all'}] plus process exclusive admission -> +// process-wide serialization against participating non-empty authorities, +// fail-closed. Explicit none() operations bypass it. + +import { + processResourceAdmissions, + type ProcessResourceAdmissionCoordinator, +} from '../process-resource-admission.js'; +import type { AuthorityContext, PreparedOperation, ResourceAuthority } from './types.js'; +import { oneShotOperation } from './one-shot-operation.js'; + +export const noneResourceAuthority = (): ResourceAuthority => ({ + async prepare(_input, context: AuthorityContext): Promise> { + const { effect } = context; + return oneShotOperation({ + claims: [], + execute: (signal, fallbackEffect) => + fallbackEffect ? fallbackEffect() : effect ? effect(signal) : Promise.resolve(), + }); + }, +}); + +export const allResourceAuthority = ( + admission: ProcessResourceAdmissionCoordinator = processResourceAdmissions, +): ResourceAuthority => ({ + async prepare(_input, context: AuthorityContext): Promise> { + const { effect } = context; + return processAllOperation( + (signal, fallbackEffect) => + fallbackEffect ? fallbackEffect() : effect ? effect(signal) : Promise.resolve(), + admission, + ); + }, +}); + +/** The single fail-closed implementation used by all() and real-effect fallbacks. */ +export function processAllOperation( + effect: (signal?: AbortSignal, fallbackEffect?: () => Promise) => Promise, + admission: ProcessResourceAdmissionCoordinator = processResourceAdmissions, +): PreparedOperation { + return oneShotOperation({ + claims: [{ kind: 'all' }], + execute: (signal, fallbackEffect) => + admission.withExclusive(signal, async () => await effect(signal, fallbackEffect)), + }); +} + +/** A pre-built `none()` operation for explicit none or synthetic/no-effect calls. */ +export function noneOperation( + effect?: (signal?: AbortSignal) => Promise, +): PreparedOperation { + return oneShotOperation({ + claims: [], + execute: (signal, fallbackEffect) => + effect ? effect(signal) : fallbackEffect ? fallbackEffect() : Promise.resolve(), + }); +} diff --git a/packages/runtime/src/preparation/target-identity.ts b/packages/runtime/src/preparation/target-identity.ts new file mode 100644 index 0000000000..5e6c824ac3 --- /dev/null +++ b/packages/runtime/src/preparation/target-identity.ts @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// packages/runtime/src/preparation/target-identity.ts +// The `(dev, ino)` execution contract captured after filesystem lease admission. +// Prepared operations retain only their canonical claim; the mutable identity is +// sampled after preceding conflicting owners have completed, immediately before +// the backend pins the object with a handle/CAS primitive. + +export type TargetIdentity = + | { readonly kind: 'file'; readonly dev: string; readonly ino: string } + | { readonly kind: 'entry'; readonly dev: string; readonly ino: string } + | { readonly kind: 'missing' }; + +export interface ResolvedTarget { + /** Backend-executable canonical path. */ + readonly canonicalPath: string; + /** Platform-normalized Scheduler/coordinator key. Never send to a backend. */ + readonly leaseKey: string; + readonly identity: TargetIdentity; +} + +/** The target a backend is authorised to use for one admitted effect. */ +export interface AdmittedTargetContract { + /** Backend-executable canonical path. Never reconstruct this from provider input. */ + readonly canonicalPath: string; + readonly semantics: 'target' | 'entry'; + /** Identity sampled while the operation owns the matching filesystem lease. */ + readonly identity: TargetIdentity; +} + +export interface ResolveIdentity { + (input: { cwd: string; path: string; semantics: 'target' | 'entry' }): Promise; +} + +/** + * True when two execution-time observations name different objects. Backends + * and tests use this for CAS/revalidation; prepare never stores an identity. + */ +export function identityChanged(a: TargetIdentity, b: TargetIdentity): boolean { + if (a.kind === 'missing' || b.kind === 'missing') return a.kind !== b.kind; + return a.dev !== b.dev || a.ino !== b.ino; +} diff --git a/packages/runtime/src/preparation/tool-authority-registry.ts b/packages/runtime/src/preparation/tool-authority-registry.ts new file mode 100644 index 0000000000..2794ee9a47 --- /dev/null +++ b/packages/runtime/src/preparation/tool-authority-registry.ts @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ResourceAuthority } from './types.js'; + +export type RegisteredToolAuthority = ResourceAuthority; +export type ToolAuthorityRegistration = readonly [ + toolId: string, + authority: RegisteredToolAuthority, +]; + +/** + * The process-owned, immutable authority registry. A canonical tool id may be + * registered exactly once; duplicate registrations fail during composition + * instead of silently changing resource semantics for later backends. + */ +export class ToolAuthorityRegistry { + readonly #authorities: ReadonlyMap; + + constructor(registrations: Iterable = []) { + const authorities = new Map(); + for (const [toolId, authority] of registrations) { + if (toolId.length === 0 || toolId.trim() !== toolId) { + throw new Error( + `Tool authority id must be a non-empty canonical id: ${JSON.stringify(toolId)}`, + ); + } + if (authorities.has(toolId)) { + throw new Error(`Tool authority is already registered: ${toolId}`); + } + authorities.set(toolId, authority); + } + this.#authorities = authorities; + } + + resolve(toolId: string): RegisteredToolAuthority | undefined { + return this.#authorities.get(toolId); + } + + has(toolId: string): boolean { + return this.#authorities.has(toolId); + } + + /** + * Return a new immutable registry containing the current registrations plus + * the supplied registrations. The constructor remains the single duplicate + * check, so a policy cannot silently replace a domain authority. + */ + withRegistrations(registrations: Iterable): ToolAuthorityRegistry { + return new ToolAuthorityRegistry([...this.#authorities, ...registrations]); + } + + get size(): number { + return this.#authorities.size; + } +} diff --git a/packages/runtime/src/preparation/tool-preparation-service.ts b/packages/runtime/src/preparation/tool-preparation-service.ts new file mode 100644 index 0000000000..8275f5fff5 --- /dev/null +++ b/packages/runtime/src/preparation/tool-preparation-service.ts @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// packages/runtime/src/preparation/tool-preparation-service.ts +// The process-level synthesis root. It is the ONLY place that fills in a +// `PreparedOperation` from a raw ToolCall: +// ① schema validation, ② canonicalisation (tool name + cwd + frozen input), +// ③ dispatch to a domain ResourceAuthority, ④ merge claims + compose execute. +// +// It never touches a real resource, and never decides ordering — those belong +// to the domain Authority and the Scheduler respectively. + +import { realpath } from 'node:fs/promises'; +import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { PermissionMode } from '@maka/core/permission'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; +import { + processResourceAdmissions, + type ProcessResourceAdmissionCoordinator, +} from '../process-resource-admission.js'; +import { oneShotOperation } from './one-shot-operation.js'; +import { allResourceAuthority } from './placeholder-authorities.js'; +import type { AuthorityContext, PreparedOperation } from './types.js'; +import type { ToolAuthorityRegistry } from './tool-authority-registry.js'; + +export interface CanonicalCallContext { + readonly sessionId: string; + readonly runId?: string; + readonly turnId: string; + readonly toolCallId: string; + readonly executionBoundary?: ExecutionBoundary; + readonly permissionMode?: PermissionMode; + readonly abortSignal?: AbortSignal; +} + +export interface CanonicalToolCall { + /** Cross-provider-normalised tool id; the key to resolveAuthority. */ + readonly toolId: string; + /** Display name for tracing. */ + readonly toolName: string; + /** Validated + deep-frozen + normalised input snapshot. */ + readonly input: Readonly; + /** Canonical (realpath'd) cwd. */ + readonly cwd: string; + readonly context: Readonly; +} + +interface SchemaValidator { + parse( + value: unknown, + ): Promise< + { readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly error: string } + >; +} + +export class ToolPreparationService { + constructor( + private readonly authorities: ToolAuthorityRegistry, + readonly processAdmission: ProcessResourceAdmissionCoordinator = processResourceAdmissions, + ) {} + + async prepare(toolCall: { + readonly tool: MakaTool; + readonly input: unknown; + readonly ctx: MakaToolContext; + }): Promise> { + // ① schema validation at the synthesis root, before any authority. A tool + // without a discoverable schema skips this step: executeTool's own arg + // validation remains the authoritative rejection. + const schema = schemaFor(toolCall.tool); + const validated = schema + ? await schema.parse(toolCall.input) + : { ok: true as const, value: toolCall.input }; + if (!validated.ok) return invalidOperation(toolCall.tool.name, validated.error); + + // ② canonicalise AFTER validation. The canonical form (not the raw provider + // shape) is what the authority and the Scheduler key on. + const canonical = await canonicalizeToolCall({ + tool: toolCall.tool, + input: validated.value, + ctx: toolCall.ctx, + }); + + // ③ dispatch only through the process-owned registry. A real tool whose + // effect has not been classified fails closed to all modelled resources. + // Synthetic/no-effect calls bypass this fallback with noneOperation(). + const authority = + this.authorities.resolve(canonical.toolId) ?? allResourceAuthority(this.processAdmission); + const operation = await authority.prepare( + canonical.input, + this.toAuthorityContext(canonical, toolCall.ctx, toolCall.tool), + ); + return oneShotOperation(this.compose([operation])); + } + + private toAuthorityContext( + canonical: CanonicalToolCall, + ctx: MakaToolContext, + tool: MakaTool, + ): AuthorityContext { + return { + sessionId: canonical.context.sessionId, + ...(canonical.context.runId ? { runId: canonical.context.runId } : {}), + turnId: canonical.context.turnId, + cwd: canonical.cwd, + ...(canonical.context.executionBoundary + ? { executionBoundary: canonical.context.executionBoundary } + : {}), + ...(canonical.context.permissionMode + ? { permissionMode: canonical.context.permissionMode } + : {}), + toolCallId: canonical.context.toolCallId, + ...(canonical.context.abortSignal ? { abortSignal: canonical.context.abortSignal } : {}), + // Placeholder authorities have no effect of their own; hand them the real + // tool implementation so execution still routes through settleToolCall. + effect: async (signal) => { + const args = structuredClone(canonical.input) as never; + return await tool.impl(args, { ...ctx, abortSignal: signal ?? ctx.abortSignal }); + }, + }; + } + + private compose( + operations: readonly PreparedOperation[], + ): PreparedOperation { + if (operations.length === 0) { + return { claims: [], execute: () => Promise.resolve() as Promise }; + } + if (operations.length === 1) return operations[0]!; + const claims = operations.flatMap((operation) => operation.claims); + return { + claims, + execute: async (signal, fallbackEffect, executionContext) => { + // Run each authority's effect in order; the first rejection stops the chain. + for (const operation of operations) { + await operation.execute(signal, fallbackEffect, executionContext); + } + return undefined as Result; + }, + }; + } +} + +/** + * Canonicalise a validated call: canonical cwd (realpath, falling back to the + * raw value), canonical tool id, and a deep-frozen input snapshot. The frozen + * snapshot is what `execute` captures — mutating the caller's live object after + * `prepare` must not affect the already-prepared operation. + */ +export async function canonicalizeToolCall(input: { + readonly tool: MakaTool; + readonly input: Input; + readonly ctx: MakaToolContext; +}): Promise> { + const taskCwd = input.ctx.cwd; + const cwd = await realpath(taskCwd).catch(() => taskCwd); + return { + toolId: input.tool.name, + toolName: input.tool.name, + input: freezeDeep(structuredClone(input.input)), + cwd, + // The context is a shallow-frozen record of scalars plus live references + // (abortSignal, executionBoundary). Deep-freezing would freeze the live + // AbortSignal and break later abort() calls. + context: Object.freeze({ + sessionId: input.ctx.sessionId, + ...(input.ctx.runId ? { runId: input.ctx.runId } : {}), + turnId: input.ctx.turnId, + toolCallId: input.ctx.toolCallId, + ...(input.ctx.executionBoundary ? { executionBoundary: input.ctx.executionBoundary } : {}), + ...(input.ctx.permissionMode ? { permissionMode: input.ctx.permissionMode } : {}), + ...(input.ctx.abortSignal ? { abortSignal: input.ctx.abortSignal } : {}), + }), + }; +} + +function freezeDeep(value: T): Readonly { + if (value === null || typeof value !== 'object') return Object.freeze(value as never); + if (Array.isArray(value)) { + for (const entry of value) freezeDeep(entry); + return Object.freeze(value) as Readonly; + } + for (const entry of Object.values(value as Record)) freezeDeep(entry); + return Object.freeze(value) as Readonly; +} + +function invalidOperation(toolName: string, detail: string): PreparedOperation { + const message = `Tool ${toolName} input could not be prepared: ${detail}`; + return oneShotOperation({ + claims: [], + execute: () => Promise.reject(new Error(message)), + }); +} + +function schemaFor(tool: MakaTool): SchemaValidator | undefined { + const parameters = tool.parameters as { + safeParse?: (value: unknown) => unknown; + validate?: (value: unknown) => unknown; + } | null; + if (!parameters) return undefined; + if (typeof parameters.safeParse === 'function') { + return { + async parse(value) { + const result = (await parameters.safeParse!(value)) as { + success: boolean; + data?: unknown; + error?: unknown; + }; + return result.success + ? { ok: true, value: result.data } + : { ok: false, error: String(result.error) }; + }, + }; + } + if (typeof parameters.validate === 'function') { + return { + async parse(value) { + const result = (await parameters.validate!(value)) as { + success?: boolean; + value?: unknown; + error?: unknown; + }; + if (result.success === true) return { ok: true, value: result.value }; + return { ok: false, error: String(result.error) }; + }, + }; + } + return undefined; +} diff --git a/packages/runtime/src/preparation/types.ts b/packages/runtime/src/preparation/types.ts new file mode 100644 index 0000000000..0750d665a7 --- /dev/null +++ b/packages/runtime/src/preparation/types.ts @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// packages/runtime/src/preparation/types.ts +// The PreparedOperation vocabulary shared by the process-level synthesis root, +// the per-batch Scheduler, and each domain ResourceAuthority. +// +// A `ResourceClaim` is a *description* used only for ordering and tracing. It is +// never a lease, never an execution permission, and never a lock. The resource +// correctness (mutual exclusion, CAS, atomicity, legal state transitions) lives +// inside each domain Authority's `PreparedOperation.execute`. + +import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { PermissionMode } from '@maka/core/permission'; + +export type KeyedClaimMode = 'read' | 'write' | 'exclusive'; + +/** + * How a keyed claim is scoped. `exact` addresses one canonical key; `tree` + * addresses every key under the canonical root (a recursive read or a big- + * grained exclusive), which is how `Grep(src)` conflicts with `Write(src/a.ts)` + * without the Scheduler needing to know it is a tree scan. + */ +export type KeyedClaimScope = 'exact' | 'tree'; + +/** + * A claim on one canonical coordination key (platform-normalized filesystem + * lease key, session id, ...). `key` is exactly the string the authority's + * `PreparedOperation.execute` uses for admission. + */ +export interface KeyedResourceClaim { + readonly kind: 'keyed'; + /** Domain namespace, e.g. 'filesystem:workspace-1' | 'session-todo'. */ + readonly authority: string; + /** Canonical coordination identity. It need not be a backend-executable path. */ + readonly key: string; + readonly mode: KeyedClaimMode; + readonly scope?: KeyedClaimScope; +} + +/** + * A capacity (permit-pool) claim. Per the Option B plan these are advisory: + * they signal backpressure, not mutual exclusion, and must NOT be folded into + * the Scheduler's conflict graph (which would turn "bounded concurrency" into + * "serialize every subagent spawn"). + */ +export interface CapacityResourceClaim { + readonly kind: 'capacity'; + readonly authority: string; + readonly key: string; + readonly permits: number; +} + +/** + * A coarse-grained claim scoped to a workspace/provider. Used to avoid + * head-of-line blocking when a coarse tool (e.g. a shell command) must not + * freeze unrelated domains. + */ +export interface CoarseResourceClaim { + readonly kind: 'coarse'; + readonly authority: string; + readonly key: string; +} + +/** Scheduler description for process-exclusive all(); explicit none() has no claims. */ +export interface AllResourceClaim { + readonly kind: 'all'; +} + +export type ResourceClaim = + | KeyedResourceClaim + | CapacityResourceClaim + | CoarseResourceClaim + | AllResourceClaim; + +/** Live ToolRuntime identity that only exists after the durable T1 cut. */ +export interface PreparedOperationExecutionContext { + readonly operationId?: string; +} + +/** + * A one-shot executable unit produced by a domain Authority. `claims` feed the + * Scheduler and tracing; `execute` is the single physical entry point that + * acquires the correctness primitives, re-validates state, runs the effect, and + * releases in `finally`. Repeated calls must be rejected. + */ +export interface PreparedOperation { + readonly claims: readonly ResourceClaim[]; + /** + * `fallbackEffect` is supplied at the ToolRuntime execute boundary. Domain + * authorities that own their effect ignore it; placeholder authorities use + * it to invoke the original tool implementation with the complete live + * ToolRuntime context. + */ + execute( + signal?: AbortSignal, + fallbackEffect?: () => Promise, + executionContext?: PreparedOperationExecutionContext, + ): Promise; +} + +/** + * Context handed to a domain Authority's `prepare`. It carries the canonical + * call context plus an optional `effect` for placeholder authorities that do + * not own an effect of their own (they invoke `context.effect` — the tool's + * real `impl`). + */ +export interface AuthorityContext { + readonly sessionId: string; + readonly runId?: string; + readonly turnId: string; + readonly cwd: string; + readonly executionBoundary?: ExecutionBoundary; + readonly permissionMode?: PermissionMode; + readonly toolCallId: string; + readonly abortSignal?: AbortSignal; + readonly effect?: (signal?: AbortSignal) => Promise; +} + +/** + * A domain Authority owns the correctness of one class of real resource. It + * receives a validated, deep-frozen, canonicalised input snapshot and computes + * the canonical identity, produces claims, and returns a `PreparedOperation`. + */ +export interface ResourceAuthority { + prepare(input: Readonly, context: AuthorityContext): Promise>; +} diff --git a/packages/runtime/src/process-resource-admission.ts b/packages/runtime/src/process-resource-admission.ts new file mode 100644 index 0000000000..3e91166a8a --- /dev/null +++ b/packages/runtime/src/process-resource-admission.ts @@ -0,0 +1,331 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +export type ProcessResourceAdmissionMode = 'shared' | 'exclusive'; +export type ProcessResourceAdmissionState = 'queued' | 'active' | 'released' | 'aborted'; + +export interface ProcessResourceAdmissionTransition { + readonly process_admission_mode: ProcessResourceAdmissionMode; + readonly process_admission_state: ProcessResourceAdmissionState; + readonly process_admission_sequence: number; + readonly process_admission_owner_id?: string; + readonly reused_owner?: boolean; +} + +export interface ProcessResourceAdmissionSnapshot { + readonly queued: readonly { + readonly sequence: number; + readonly mode: ProcessResourceAdmissionMode; + }[]; + readonly activeShared: number; + readonly activeExclusive: boolean; +} + +export interface ProcessResourceAdmissionCoordinator { + withShared( + signal: AbortSignal | undefined, + effect: () => Promise, + ): Promise; + withExclusive( + signal: AbortSignal | undefined, + effect: () => Promise, + ): Promise; + /** Read-only diagnostics; resource identities are deliberately absent. */ + inspect(): ProcessResourceAdmissionSnapshot; +} + +export interface ProcessResourceAdmissionCoordinatorOptions { + /** Best-effort tracing hook. Observer failures never affect correctness. */ + readonly onTransition?: (transition: ProcessResourceAdmissionTransition) => void; +} + +type WaiterState = 'queued' | 'active' | 'settled'; + +interface Waiter { + readonly sequence: number; + readonly mode: ProcessResourceAdmissionMode; + readonly signal?: AbortSignal; + readonly effect: () => Promise; + state: WaiterState; + readonly resolve: (value: Result | PromiseLike) => void; + readonly reject: (reason?: unknown) => void; + removeAbortListener?: () => void; +} + +interface AdmissionOwner { + readonly id: string; + readonly sequence: number; + readonly mode: ProcessResourceAdmissionMode; + active: boolean; + references: number; + readonly settled: Promise; + readonly settle: () => void; +} + +export class ProcessResourceAdmissionUpgradeError extends Error { + override readonly name = 'ProcessResourceAdmissionUpgradeError'; + readonly code = 'process_admission_upgrade_not_allowed'; + + constructor() { + super('A shared process admission owner cannot be upgraded to exclusive'); + } +} + +/** + * Create a writer-fair FIFO shared/exclusive process admission coordinator. + * Explicit none() operations do not call this coordinator at all. + */ +export function createProcessResourceAdmissionCoordinator( + options: ProcessResourceAdmissionCoordinatorOptions = {}, +): ProcessResourceAdmissionCoordinator { + let nextSequence = 0; + const queued: Waiter[] = []; + const activeShared = new Set>(); + let activeExclusive: Waiter | undefined; + const ownerStorage = new AsyncLocalStorage(); + + const emit = (transition: ProcessResourceAdmissionTransition): void => { + try { + options.onTransition?.(transition); + } catch { + // Tracing is observational. It must never strand or reject an admission. + } + }; + + const withMode = ( + mode: ProcessResourceAdmissionMode, + signal: AbortSignal | undefined, + effect: () => Promise, + ): Promise => { + // Pre-aborted work never executes, including a compatible reentrant call. + if (signal?.aborted) return Promise.reject(abortReason(signal)); + + const currentOwner = ownerStorage.getStore(); + if (currentOwner?.active) { + if (currentOwner.mode === 'shared' && mode === 'exclusive') { + return Promise.reject(new ProcessResourceAdmissionUpgradeError()); + } + currentOwner.references += 1; + emit({ + process_admission_mode: mode, + process_admission_state: 'active', + process_admission_sequence: currentOwner.sequence, + process_admission_owner_id: currentOwner.id, + reused_owner: true, + }); + return runReentrant(currentOwner, effect); + } + + let resolve!: (value: Result | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const result = new Promise((resolveResult, rejectResult) => { + resolve = resolveResult; + reject = rejectResult; + }); + const waiter: Waiter = { + sequence: nextSequence++, + mode, + ...(signal ? { signal } : {}), + effect, + state: 'queued', + resolve, + reject, + }; + queued.push(waiter as Waiter); + emit({ + process_admission_mode: mode, + process_admission_state: 'queued', + process_admission_sequence: waiter.sequence, + }); + listenForQueuedAbort(waiter); + drain(); + return result; + }; + + const runReentrant = async ( + owner: AdmissionOwner, + effect: () => Promise, + ): Promise => { + try { + return await ownerStorage.run(owner, effect); + } finally { + releaseOwnerReference(owner); + } + }; + + const listenForQueuedAbort = (waiter: Waiter): void => { + if (!waiter.signal) return; + const listener = () => cancelQueued(waiter); + waiter.signal.addEventListener('abort', listener, { once: true }); + waiter.removeAbortListener = () => waiter.signal?.removeEventListener('abort', listener); + // Abort dispatch is synchronous, but the signal may have changed between + // the preflight check and listener registration. + if (waiter.signal.aborted) cancelQueued(waiter); + }; + + const removeAbortListener = (waiter: Waiter): void => { + waiter.removeAbortListener?.(); + waiter.removeAbortListener = undefined; + }; + + const cancelQueued = (waiter: Waiter): void => { + if (waiter.state !== 'queued') return; + const index = queued.indexOf(waiter as Waiter); + if (index < 0) return; + queued.splice(index, 1); + removeAbortListener(waiter as Waiter); + waiter.state = 'settled'; + emit({ + process_admission_mode: waiter.mode, + process_admission_state: 'aborted', + process_admission_sequence: waiter.sequence, + }); + drain(); + waiter.reject(abortReason(waiter.signal)); + }; + + const drain = (): void => { + if (activeExclusive) return; + if (activeShared.size > 0) { + while (!activeExclusive && queued[0]?.mode === 'shared') startHead(); + return; + } + if (queued[0]?.mode === 'exclusive') { + startHead(); + return; + } + while (!activeExclusive && queued[0]?.mode === 'shared') startHead(); + }; + + const startHead = (): void => { + const waiter = queued.shift(); + if (!waiter || waiter.state !== 'queued') return; + if (waiter.signal?.aborted) { + removeAbortListener(waiter); + waiter.state = 'settled'; + emit({ + process_admission_mode: waiter.mode, + process_admission_state: 'aborted', + process_admission_sequence: waiter.sequence, + }); + drain(); + waiter.reject(abortReason(waiter.signal)); + return; + } + removeAbortListener(waiter); + waiter.state = 'active'; + if (waiter.mode === 'shared') activeShared.add(waiter); + else activeExclusive = waiter; + + const owner = createOwner(waiter); + emit({ + process_admission_mode: waiter.mode, + process_admission_state: 'active', + process_admission_sequence: waiter.sequence, + process_admission_owner_id: owner.id, + }); + void runActive(waiter, owner); + }; + + const runActive = async (waiter: Waiter, owner: AdmissionOwner): Promise => { + let outcome: PromiseSettledResult; + try { + const value = await ownerStorage.run(owner, waiter.effect); + outcome = { status: 'fulfilled', value }; + } catch (reason) { + outcome = { status: 'rejected', reason }; + } finally { + releaseOwnerReference(owner); + } + + // A root effect can start nested work without awaiting it. Keep the root + // holder active until every nested owner reference has really settled. + await owner.settled; + finishActive(waiter, owner, outcome); + }; + + const finishActive = ( + waiter: Waiter, + owner: AdmissionOwner, + outcome: PromiseSettledResult, + ): void => { + if (waiter.mode === 'shared') activeShared.delete(waiter); + else if (activeExclusive === waiter) activeExclusive = undefined; + waiter.state = 'settled'; + emit({ + process_admission_mode: waiter.mode, + process_admission_state: 'released', + process_admission_sequence: waiter.sequence, + process_admission_owner_id: owner.id, + }); + // Release, then offer the queue one drain opportunity, then settle the + // outward promise. Callers can never observe settlement while still held. + drain(); + if (outcome.status === 'fulfilled') waiter.resolve(outcome.value); + else waiter.reject(outcome.reason); + }; + + const createOwner = (waiter: Waiter): AdmissionOwner => { + let settle!: () => void; + const settled = new Promise((resolve) => { + settle = resolve; + }); + return { + id: `process-admission-${waiter.sequence}`, + sequence: waiter.sequence, + mode: waiter.mode, + active: true, + references: 1, + settled, + settle, + }; + }; + + const releaseOwnerReference = (owner: AdmissionOwner): void => { + if (!owner.active || owner.references <= 0) return; + owner.references -= 1; + if (owner.references !== 0) return; + // Flip active before resolving so stale AsyncLocalStorage descendants + // cannot reuse this owner after its last real effect completed. + owner.active = false; + owner.settle(); + }; + + return { + withShared: (signal, effect) => withMode('shared', signal, effect), + withExclusive: (signal, effect) => withMode('exclusive', signal, effect), + inspect: () => ({ + queued: queued.map(({ sequence, mode }) => ({ sequence, mode })), + activeShared: activeShared.size, + activeExclusive: activeExclusive !== undefined, + }), + }; +} + +function abortReason(signal: AbortSignal | undefined): unknown { + if (signal?.reason !== undefined) return signal.reason; + return Object.assign(new Error('Process resource admission was aborted before it started'), { + name: 'AbortError', + }); +} + +/** One process lifetime owner shared by every production Runtime Host path. */ +export const processResourceAdmissions = createProcessResourceAdmissionCoordinator(); diff --git a/packages/runtime/src/tool-access.ts b/packages/runtime/src/tool-access.ts new file mode 100644 index 0000000000..76e6cd89cd --- /dev/null +++ b/packages/runtime/src/tool-access.ts @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { posix, win32 } from 'node:path'; + +export type ToolFileOperation = 'read' | 'search' | 'write' | 'readwrite'; +export type ToolKeyOperation = 'read' | 'write'; + +export type ToolResourceAccess = + | { + readonly kind: 'file'; + readonly path: string; + readonly operation: ToolFileOperation; + readonly recursive?: boolean; + } + | { + readonly kind: 'key'; + readonly key: string; + readonly operation: ToolKeyOperation; + } + | { readonly kind: 'all' }; + +export type ToolAccesses = readonly ToolResourceAccess[]; + +export interface NormalizeToolAccessOptions { + readonly cwd?: string; + readonly platform?: NodeJS.Platform; +} + +const FILE_OPERATIONS = new Set(['read', 'search', 'write', 'readwrite']); +const KEY_OPERATIONS = new Set(['read', 'write']); +const NO_ACCESSES: ToolAccesses = Object.freeze([]); +const ALL_ACCESSES: ToolAccesses = Object.freeze([Object.freeze({ kind: 'all' as const })]); + +/** Constructors for the complete resource set a single tool call may access. */ +export const ToolAccesses = { + none(): ToolAccesses { + return NO_ACCESSES; + }, + + all(): ToolAccesses { + return ALL_ACCESSES; + }, + + file( + operation: ToolFileOperation, + path: string, + options: NormalizeToolAccessOptions & { readonly recursive?: boolean } = {}, + ): ToolAccesses { + return [ + { + kind: 'file', + operation, + path: normalizeToolFilePath(path, options), + ...(options.recursive === true ? { recursive: true } : {}), + }, + ]; + }, + + readFile(path: string, options?: NormalizeToolAccessOptions): ToolAccesses { + return ToolAccesses.file('read', path, options); + }, + + readTree(path: string, options?: NormalizeToolAccessOptions): ToolAccesses { + return ToolAccesses.file('read', path, { ...options, recursive: true }); + }, + + writeFile(path: string, options?: NormalizeToolAccessOptions): ToolAccesses { + return ToolAccesses.file('write', path, options); + }, + + writeTree(path: string, options?: NormalizeToolAccessOptions): ToolAccesses { + return ToolAccesses.file('write', path, { ...options, recursive: true }); + }, + + readWriteFile(path: string, options?: NormalizeToolAccessOptions): ToolAccesses { + return ToolAccesses.file('readwrite', path, options); + }, + + readWriteTree(path: string, options?: NormalizeToolAccessOptions): ToolAccesses { + return ToolAccesses.file('readwrite', path, { ...options, recursive: true }); + }, + + searchTree(path: string, options?: NormalizeToolAccessOptions): ToolAccesses { + return ToolAccesses.file('search', path, { ...options, recursive: true }); + }, + + readKey(key: string): ToolAccesses { + return [{ kind: 'key', key: normalizeToolKey(key), operation: 'read' }]; + }, + + writeKey(key: string): ToolAccesses { + return [{ kind: 'key', key: normalizeToolKey(key), operation: 'write' }]; + }, +} as const; + +/** + * Normalize and validate declarations before they enter a Scheduler. This is + * lexical only: resolving symlinks or junctions belongs to tool preparation, + * never the Scheduler's hot conflict path. + */ +export function normalizeToolAccesses( + accesses: ToolAccesses, + options: NormalizeToolAccessOptions = {}, +): ToolAccesses { + if (!Array.isArray(accesses)) throw new TypeError('Tool accesses must be an array'); + if (accesses.length === 0) return ToolAccesses.none(); + + return accesses.map((access): ToolResourceAccess => { + if (!access || typeof access !== 'object') { + throw new TypeError('Tool access entries must be objects'); + } + if (access.kind === 'all') return { kind: 'all' }; + if (access.kind === 'file') { + if (!FILE_OPERATIONS.has(access.operation)) { + throw new TypeError(`Unsupported file access operation: ${String(access.operation)}`); + } + return { + kind: 'file', + operation: access.operation, + path: normalizeToolFilePath(access.path, options), + ...(access.recursive === true ? { recursive: true } : {}), + }; + } + if (access.kind === 'key') { + if (!KEY_OPERATIONS.has(access.operation)) { + throw new TypeError(`Unsupported key access operation: ${String(access.operation)}`); + } + return { + kind: 'key', + key: normalizeToolKey(access.key), + operation: access.operation, + }; + } + throw new TypeError( + `Unsupported tool access kind: ${String((access as { kind?: unknown }).kind)}`, + ); + }); +} + +export function normalizeToolFilePath( + path: string, + options: NormalizeToolAccessOptions = {}, +): string { + if (typeof path !== 'string' || path.trim().length === 0) { + throw new TypeError('File access path must be a non-empty string'); + } + const platform = options.platform ?? process.platform; + const pathApi = platform === 'win32' ? win32 : posix; + const cwd = options.cwd ?? process.cwd(); + let normalized = pathApi.resolve(cwd, path).replaceAll('\\', '/'); + const root = pathApi.parse(pathApi.resolve(cwd, path)).root.replaceAll('\\', '/'); + while (normalized.length > root.length && normalized.endsWith('/')) + normalized = normalized.slice(0, -1); + if (platform === 'win32') normalized = normalized.toLowerCase(); + return normalized; +} + +export function toolAccessesConflict(left: ToolAccesses, right: ToolAccesses): boolean { + return left.some((leftAccess) => + right.some((rightAccess) => toolResourceAccessesConflict(leftAccess, rightAccess)), + ); +} + +export function toolResourceAccessesConflict( + left: ToolResourceAccess, + right: ToolResourceAccess, +): boolean { + if (left.kind === 'all' || right.kind === 'all') return true; + if (left.kind !== right.kind) return false; + if (left.kind === 'key' && right.kind === 'key') { + return left.key === right.key && (left.operation === 'write' || right.operation === 'write'); + } + if (left.kind === 'file' && right.kind === 'file') { + if (!fileOperationWrites(left.operation) && !fileOperationWrites(right.operation)) return false; + return fileRangesOverlap(left, right); + } + return false; +} + +function fileOperationWrites(operation: ToolFileOperation): boolean { + return operation === 'write' || operation === 'readwrite'; +} + +function fileRangesOverlap( + left: Extract, + right: Extract, +): boolean { + if (left.path === right.path) return true; + return ( + (left.recursive === true && isPathWithin(left.path, right.path)) || + (right.recursive === true && isPathWithin(right.path, left.path)) + ); +} + +function isPathWithin(parent: string, candidate: string): boolean { + const prefix = parent.endsWith('/') ? parent : `${parent}/`; + return candidate.startsWith(prefix); +} + +function normalizeToolKey(key: string): string { + if (typeof key !== 'string' || key.trim().length === 0) { + throw new TypeError('Logical resource key must be a non-empty string'); + } + return key.trim(); +} diff --git a/packages/runtime/src/tool-call-batch.ts b/packages/runtime/src/tool-call-batch.ts new file mode 100644 index 0000000000..4afd1c3933 --- /dev/null +++ b/packages/runtime/src/tool-call-batch.ts @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PreparedOperation } from './preparation/types.js'; +import { processAllOperation } from './preparation/placeholder-authorities.js'; +import { + processResourceAdmissions, + type ProcessResourceAdmissionCoordinator, +} from './process-resource-admission.js'; +import { ToolScheduler } from './tool-scheduler.js'; + +export interface ToolCallBatchEntry { + readonly id: string; + readonly signal?: AbortSignal; + /** + * The synthesis root's single entry point. It validates, canonicalises and + * dispatches to the authority, returning a one-shot PreparedOperation. A + * reject here is treated as an unclassified real effect and scheduled with + * all claims. The caller still owns the fallback effect and settlement. + */ + readonly prepare: () => Promise>; + /** + * Execute the prepared operation. Receives `undefined` when preparation + * failed, in which case the caller runs its normal effect under all claims. + */ + readonly run: (operation: PreparedOperation | undefined) => Promise | Result; +} + +export interface SettleToolCallBatchOptions { + readonly processAdmission?: ProcessResourceAdmissionCoordinator; +} + +/** + * Prepare every call behind one barrier, submit by original array index, and + * return settled outcomes in that same order regardless of completion order. + * Claims are produced by `prepare`. If preparation rejects but the caller will + * still run a real effect, the fallback is all() so it cannot fail open. + */ +export async function settleToolCallBatch( + entries: readonly ToolCallBatchEntry[], + options: SettleToolCallBatchOptions = {}, +): Promise[]> { + const processAdmission = options.processAdmission ?? processResourceAdmissions; + const scheduler = new ToolScheduler(); + const slots = entries.map((entry, index) => ({ entry, sequence: index })); + const prepared = await Promise.all( + slots.map(async (slot) => { + try { + return { slot, operation: await slot.entry.prepare() }; + } catch { + // A preparation failure does not prove the fallback effect is harmless. + // Synthetic/no-effect calls return noneOperation() before reaching here. + return { slot, operation: undefined }; + } + }), + ); + + const resultSlots = prepared.map(({ slot, operation }) => { + const runnable: PreparedOperation = + operation ?? + processAllOperation(async () => await slot.entry.run(undefined), processAdmission); + return scheduler.add({ + id: slot.entry.id, + sequence: slot.sequence, + operation: runnable, + ...(slot.entry.signal ? { signal: slot.entry.signal } : {}), + run: async (candidate, signal) => + operation ? await slot.entry.run(operation) : ((await candidate.execute(signal)) as Result), + }); + }); + return await Promise.allSettled(resultSlots); +} diff --git a/packages/runtime/src/tool-preparation.ts b/packages/runtime/src/tool-preparation.ts new file mode 100644 index 0000000000..aa6bfe3301 --- /dev/null +++ b/packages/runtime/src/tool-preparation.ts @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ToolPreparationService } from './preparation/tool-preparation-service.js'; +export { + ToolAuthorityRegistry, + type RegisteredToolAuthority, + type ToolAuthorityRegistration, +} from './preparation/tool-authority-registry.js'; +export { + EXPLICIT_ALL_TOOL_AUTHORITY_IDS, + EXPLICIT_NONE_TOOL_AUTHORITY_IDS, + defaultToolAuthorityRegistrations, +} from './preparation/default-tool-authorities.js'; +export type { + AuthorityContext, + PreparedOperation, + ResourceAuthority, + ResourceClaim, +} from './preparation/types.js'; +export type { + ComputerAuthority, + ComputerResourceAuthority, + ComputerResourceOperation, + DeepResearchAuthority, + DomainAccessMode, + GoalAuthority, + GoalResourceAuthority, + GoalResourceOperation, + HistoryAuthority, + HistoryResourceAuthority, + HistoryResourceOperation, + MemoryAuthority, + PlanAuthority, + PlanResourceAuthority, + PlanResourceOperation, + PreciseDomainAuthority, + PreciseDomainAuthorityKind, + PreciseDomainResourceIdentity, + ScheduledTaskAuthority, + ScheduledTaskResourceAuthority, + ScheduledTaskResourceOperation, + SessionDomainAuthority, + SessionDomainOperation, + SessionTodoAuthority, + SessionTodoResourceAuthority, + SessionTodoResourceOperation, + ShellRunAuthority, + ShellRunResourceAuthority, + ShellRunResourceOperation, + SkillCatalogAuthority, + SkillCatalogResourceAuthority, + SkillCatalogResourceOperation, + TerminalBackgroundAuthority, +} from './preparation/domain-authority-contracts.js'; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a12fdce73e..18b569c813 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -136,6 +136,17 @@ import { type RuntimeInteractionClosureReason, type RuntimeUserQuestionClosureReason, } from './interaction-authority.js'; +import type { ToolAccesses } from './tool-access.js'; + +export type ToolStepAdmission = + | { readonly kind: 'admitted' } + | { readonly kind: 'rejected'; readonly reason: string }; + +export type PreparedToolEffect = ( + signal: AbortSignal, + fallbackEffect: () => Promise, + executionContext: MakaToolContext, +) => Promise; export interface ResolvedMakaToolCall { tool: MakaTool; @@ -150,6 +161,15 @@ export interface ResolvedMakaToolCall { parentToolCallId?: string; parentOperationId?: string; maxResultBytes?: number; + /** Precomputed provider-batch admission; nested/direct calls omit it. */ + stepAdmission?: ToolStepAdmission; + /** + * Precomputed PreparedOperation.execute (the authority's correctness+effect + * shell). It always runs inside the execute boundary, even with zero claims. + * Placeholder operations invoke `fallbackEffect`, which preserves the full + * live ToolRuntime context for the original `tool.impl`. + */ + effect?: PreparedToolEffect; } export interface DurableSessionEventSink { @@ -174,6 +194,11 @@ export type MakaToolPreparationContext = Pick< | 'abortSignal' >; +export type MakaToolAccessContext = Pick< + MakaToolContext, + 'sessionId' | 'runId' | 'turnId' | 'cwd' | 'permissionMode' | 'toolCallId' | 'abortSignal' +>; + export interface PreparedMakaToolExecution { execute(context: MakaToolContext): Promise | R; cancel(): Promise | void; @@ -216,6 +241,17 @@ export interface MakaTool

{ managedMutationTransform?: (args: P) => Promise | R; /** Step-level admission contract. Exclusive tools cannot share an assistant step. */ executionSemantics?: 'parallel' | 'exclusive_step'; + /** + * Pure, call-level declaration of every Scheduler-managed resource this + * invocation may access. Omission fails closed to a global access in the + * batch runner. This hook must not perform the tool's real side effect. + * @deprecated Access planning has moved out of the Scheduler into the + * resource Authority's PreparedOperation; register an authority instead. + */ + resolveAccesses?: ( + args: P, + context: MakaToolAccessContext, + ) => ToolAccesses | undefined | Promise; /** Nested CodeMode admission. Ordinary tools are nestable by default. */ nesting?: 'nestable' | 'direct_only'; /** Optional permission/persistence projection derived from isolated execution args. */ @@ -858,8 +894,10 @@ export class ToolRuntime { ...(call.parentOperationId ? { parentOperationId: call.parentOperationId } : {}), ...(call.maxResultBytes !== undefined ? { maxResultBytes: call.maxResultBytes } : {}), ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), + ...(call.effect ? { effect: call.effect } : {}), }, call.stepId, + call.stepAdmission, ); const providerError = providerToolErrorMessage(result); return { result, ...(providerError ? { providerError } : {}) }; @@ -1115,8 +1153,10 @@ export class ToolRuntime { parentToolCallId?: string; parentOperationId?: string; maxResultBytes?: number; + effect?: PreparedToolEffect; }, stepId?: string, + stepAdmission?: ToolStepAdmission, ): Promise { const rawExecutionArgs = snapshotToolArgs(args); const sandboxBoundaryDecisionGeneration = this.sandboxBoundaryDecisionGeneration; @@ -1127,7 +1167,13 @@ export class ToolRuntime { ctx.origin === 'code_mode' && tool.nesting === 'direct_only' ? `Tool ${tool.name} is direct-only and cannot run inside exec.` : undefined; - const admissionFailure = directOnlyFailure ?? this.admitToolForStep(tool, stepId); + const admissionFailure = + directOnlyFailure ?? + (stepAdmission?.kind === 'rejected' + ? stepAdmission.reason + : stepAdmission?.kind === 'admitted' + ? undefined + : this.admitToolForStep(tool, stepId)); const executionArgs = rawExecutionArgs; let permissionArgs = executionArgs; let permissionArgsError: unknown; @@ -1744,10 +1790,14 @@ export class ToolRuntime { queue, ), }; - const invokeTool = () => - preparedExecution + const invokeFallbackEffect = async () => + await (preparedExecution ? preparedExecution.execute(toolContext) - : tool.impl(structuredClone(executionArgs) as never, toolContext); + : tool.impl(structuredClone(executionArgs) as never, toolContext)); + const invokeTool = () => + ctx.effect + ? ctx.effect(toolContext.abortSignal, invokeFallbackEffect, toolContext) + : invokeFallbackEffect(); const invokeManagedTransform = () => tool.managedMutationTransform!(structuredClone(executionArgs) as never); const prepareOperationValue = async ( @@ -2463,6 +2513,18 @@ export class ToolRuntime { return undefined; } + /** + * Reserve one provider step's control-plane admission in model order before + * resource scheduling begins. Rejected calls still settle through Runtime, + * but their batch task can safely declare no resource accesses. + */ + admitToolCallBatch(tools: readonly MakaTool[], stepId: string | undefined): ToolStepAdmission[] { + return tools.map((tool) => { + const reason = this.admitToolForStep(tool, stepId); + return reason ? { kind: 'rejected', reason } : { kind: 'admitted' }; + }); + } + private assertDurableDispatchNotAborted(toolName: string, abortSignal: AbortSignal): void { if (!abortSignal.aborted) return; throw abortSignal.reason instanceof Error diff --git a/packages/runtime/src/tool-scheduler.ts b/packages/runtime/src/tool-scheduler.ts new file mode 100644 index 0000000000..51ea4a14e2 --- /dev/null +++ b/packages/runtime/src/tool-scheduler.ts @@ -0,0 +1,250 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { claimsConflict } from './preparation/claims.js'; +import type { PreparedOperation } from './preparation/types.js'; + +export interface ToolSchedulerTask { + readonly id: string; + readonly sequence: number; + /** The prepared operation whose claims drive ordering and whose run is executed. */ + readonly operation: PreparedOperation; + readonly signal?: AbortSignal; + /** + * Execute the prepared operation. Kept on the task (rather than scheduler + * calling `operation.execute` directly) so the caller can wrap the one-shot + * effect inside `settleToolCall` and keep admission / durable / projection + * ownership outside the pure sequencer. + */ + readonly run: ( + operation: PreparedOperation, + signal?: AbortSignal, + ) => Promise | Result; +} + +type ScheduledTaskState = 'queued' | 'active' | 'finished'; + +interface ScheduledTask extends ToolSchedulerTask { + state: ScheduledTaskState; + readonly result: Promise; + readonly resolve: (value: Result | PromiseLike) => void; + readonly reject: (reason?: unknown) => void; + abortListener?: () => void; +} + +/** + * Batch-local, conflict-aware Scheduler. A later task may bypass queued work + * only when it conflicts with neither active work nor an earlier queued task. + * The Scheduler is a pure deterministic sequencer: it reads `claims` to decide + * ordering and never resolves an identity or touches a resource. The resource + * correctness lives in each `PreparedOperation.execute`. + */ +export class ToolScheduler { + private readonly activeTasks: ScheduledTask[] = []; + private queuedTasks: ScheduledTask[] = []; + private lastSequence = -1; + /** Fail-stop latch: a rejected task freezes further dispatch. */ + private frozen = false; + + add(task: ToolSchedulerTask): Promise { + if (!Number.isSafeInteger(task.sequence) || task.sequence <= this.lastSequence) { + throw new Error( + `Tool Scheduler tasks must be submitted once in strictly increasing sequence order (received ${task.sequence} after ${this.lastSequence})`, + ); + } + this.lastSequence = task.sequence; + + let resolve!: (value: Result | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const result = new Promise((resolveResult, rejectResult) => { + resolve = resolveResult; + reject = rejectResult; + }); + const scheduled: ScheduledTask = { + ...task, + state: 'queued', + result, + resolve, + reject, + }; + + if (this.frozen) { + scheduled.state = 'finished'; + scheduled.reject( + new Error( + `Tool Scheduler is frozen after a prior rejection; task ${task.id} was never started`, + ), + ); + return result; + } + + if (task.signal?.aborted) { + scheduled.state = 'finished'; + scheduled.reject(abortReason(task.signal, task.id)); + return result; + } + + if (this.isBlocked(scheduled as ScheduledTask, this.queuedTasks)) { + this.queuedTasks.push(scheduled as ScheduledTask); + this.listenForQueuedAbort(scheduled); + } else { + this.startTask(scheduled); + } + return result; + } + + get activeCount(): number { + return this.activeTasks.length; + } + + get queuedCount(): number { + return this.queuedTasks.length; + } + + private isBlocked( + task: ScheduledTask, + queuedBefore: readonly ScheduledTask[], + ): boolean { + return ( + this.conflictsWithAny(task, this.activeTasks) || this.conflictsWithAny(task, queuedBefore) + ); + } + + private conflictsWithAny( + task: ScheduledTask, + candidates: readonly ScheduledTask[], + ): boolean { + return candidates.some((candidate) => + claimsConflict(task.operation.claims, candidate.operation.claims), + ); + } + + private startTask(task: ScheduledTask): void { + if (this.frozen) { + task.state = 'finished'; + task.reject(new Error(`Tool Scheduler is frozen; task ${task.id} was never started`)); + return; + } + if (task.state !== 'queued') { + task.reject( + new Error(`Tool Scheduler invariant violated: task ${task.id} started from ${task.state}`), + ); + return; + } + this.removeQueuedAbortListener(task); + task.state = 'active'; + this.activeTasks.push(task as ScheduledTask); + + let execution: Promise; + try { + execution = Promise.resolve(task.run(task.operation, task.signal)); + } catch (error) { + execution = Promise.reject(error); + } + void execution.then( + (value) => this.finishTask(task, { status: 'fulfilled', value }), + (reason: unknown) => this.finishTask(task, { status: 'rejected', reason }), + ); + } + + private finishTask( + task: ScheduledTask, + outcome: PromiseSettledResult, + ): void { + if (task.state !== 'active') { + task.reject( + new Error(`Tool Scheduler invariant violated: task ${task.id} finished from ${task.state}`), + ); + return; + } + const index = this.activeTasks.indexOf(task as ScheduledTask); + if (index < 0) { + task.state = 'finished'; + task.reject( + new Error(`Tool Scheduler invariant violated: active task ${task.id} was missing`), + ); + return; + } + this.activeTasks.splice(index, 1); + task.state = 'finished'; + if (outcome.status === 'fulfilled') { + task.resolve(outcome.value); + } else { + // §6.2 fail-stop: settleToolCall has already normalised business failures + // into fulfilled error results, so a rejection is infrastructure/turn-fatal. + this.frozen = true; + task.reject(outcome.reason); + } + this.drainQueue(); + } + + private drainQueue(): void { + if (this.frozen) { + const toCancel = this.queuedTasks.filter((task) => task.state === 'queued'); + this.queuedTasks = []; + for (const task of toCancel) { + this.removeQueuedAbortListener(task); + task.state = 'finished'; + task.reject(new Error(`Tool Scheduler is frozen; queued task ${task.id} was cancelled`)); + } + return; + } + const waiting: ScheduledTask[] = []; + for (const task of this.queuedTasks) { + if (task.state !== 'queued') continue; + if (this.isBlocked(task, waiting)) waiting.push(task); + else this.startTask(task); + } + this.queuedTasks = waiting; + } + + private listenForQueuedAbort(task: ScheduledTask): void { + if (!task.signal) return; + const listener = () => this.cancelQueuedTask(task); + task.abortListener = listener; + task.signal.addEventListener('abort', listener, { once: true }); + // Abort can race the listener registration between add()'s first check and + // this call. Recheck so a queued task can never become permanently stuck. + if (task.signal.aborted) this.cancelQueuedTask(task); + } + + private cancelQueuedTask(task: ScheduledTask): void { + if (task.state !== 'queued') return; + const index = this.queuedTasks.indexOf(task as ScheduledTask); + if (index < 0) return; + this.queuedTasks.splice(index, 1); + this.removeQueuedAbortListener(task); + task.state = 'finished'; + task.reject(abortReason(task.signal, task.id)); + this.drainQueue(); + } + + private removeQueuedAbortListener(task: ScheduledTask): void { + if (!task.signal || !task.abortListener) return; + task.signal.removeEventListener('abort', task.abortListener); + task.abortListener = undefined; + } +} + +function abortReason(signal: AbortSignal | undefined, taskId: string): unknown { + if (signal?.reason !== undefined) return signal.reason; + return Object.assign(new Error(`Tool task ${taskId} was cancelled before it started`), { + name: 'AbortError', + }); +} diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 6fa76ee09f..f09ed1dfd8 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -33,6 +33,7 @@ import { openStableTarget, writeThroughHandle, } from './file-stable-write.js'; +import { readStableTarget, type StableReadExpectedIdentity } from './file-stable-read.js'; import { promisify } from 'node:util'; import type { ToolExecutionFacts } from '@maka/core/permission'; import { runProcessWithBoundedTail, runShellWithBoundedTail } from './shell-exec.js'; @@ -100,6 +101,15 @@ export interface WorkspaceReadImageResult { export type WorkspaceReadFileResult = WorkspaceReadTextResult | WorkspaceReadImageResult; +export interface WorkspaceStableReadInput extends WorkspaceReadFileInput { + /** The object observed after filesystem lease admission. */ + expectedIdentity: StableReadExpectedIdentity; +} + +export interface WorkspaceStableReadExecutor { + stableReadFile(input: WorkspaceStableReadInput): Promise; +} + export interface WorkspaceWriteFileInput { cwd: string; path: string; @@ -191,7 +201,10 @@ export interface WorkspaceWriteLockKeyInput { } export interface WorkspaceWriteLockKeyResult { + /** Canonical coordination identity retained for compatibility. */ key: string; + /** Canonical path that may be passed back to this workspace executor. */ + canonicalPath: string; } export interface WorkspaceGlobInput { @@ -294,6 +307,7 @@ export interface WorkspaceExecutor WorkspaceGlobExecutor, WorkspaceGrepExecutor, Partial, + Partial, Partial {} export class LocalWorkspaceExecutor implements WorkspaceExecutor { @@ -335,6 +349,10 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { return { content: lines.slice(start, end).join('\n') }; } + async stableReadFile(input: WorkspaceStableReadInput): Promise { + return await readStableTarget(input); + } + async writeFile(input: WorkspaceWriteFileInput): Promise { await fs.writeFile(input.path, input.content, 'utf8'); return { @@ -436,7 +454,7 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { input.semantics === 'entry' ? (await resolveCanonicalDirectoryEntryTarget(input.cwd, input.path)).path : (await canonicalPathUnderCwd(input.cwd, input.path)).path; - return { key: path }; + return { key: path, canonicalPath: path }; } async globFiles(input: WorkspaceGlobInput): Promise {