From e6a8b7ebad8f3971c43d634817a70cc71a0f66c1 Mon Sep 17 00:00:00 2001 From: jarad-z <2280669499@qq.com> Date: Wed, 2 Sep 2026 14:35:54 +0800 Subject: [PATCH 1/6] feat(runtime): schedule tool calls by resource access Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 108 ++ .../src/__tests__/builtin-tool-access.test.ts | 119 +++ .../runtime/src/__tests__/tool-access.test.ts | 116 +++ .../src/__tests__/tool-call-batch.test.ts | 212 ++++ .../__tests__/tool-runtime-settlement.test.ts | 32 + .../src/__tests__/tool-scheduler.test.ts | 275 +++++ packages/runtime/src/builtin-tools.ts | 23 + packages/runtime/src/tool-access.ts | 222 ++++ packages/runtime/src/tool-call-batch.ts | 115 ++ packages/runtime/src/tool-runtime.ts | 43 +- packages/runtime/src/tool-scheduler.ts | 201 ++++ tool-runtime-task-scheduler-architecture.md | 978 ++++++++++++++++++ 12 files changed, 2443 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/__tests__/builtin-tool-access.test.ts create mode 100644 packages/runtime/src/__tests__/tool-access.test.ts create mode 100644 packages/runtime/src/__tests__/tool-call-batch.test.ts create mode 100644 packages/runtime/src/__tests__/tool-scheduler.test.ts create mode 100644 packages/runtime/src/tool-access.ts create mode 100644 packages/runtime/src/tool-call-batch.ts create mode 100644 packages/runtime/src/tool-scheduler.ts create mode 100644 tool-runtime-task-scheduler-architecture.md diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 055875101e..bc0718606a 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -95,6 +95,7 @@ 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'; describe('AiSdkBackend ApplyPatch routing', () => { @@ -10992,6 +10993,113 @@ 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']), + }), + resolveAccesses: ({ path, operation }, context) => + operation === 'read' + ? ToolAccesses.readFile(path, { cwd: context.cwd }) + : ToolAccesses.writeFile(path, { cwd: context.cwd }), + impl: async ({ label }) => { + 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], + 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__/builtin-tool-access.test.ts b/packages/runtime/src/__tests__/builtin-tool-access.test.ts new file mode 100644 index 0000000000..5f6cda1e06 --- /dev/null +++ b/packages/runtime/src/__tests__/builtin-tool-access.test.ts @@ -0,0 +1,119 @@ +/* + * 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 { resolve } from 'node:path'; +import { describe, test } from 'node:test'; +import { buildBuiltinTools } from '../builtin-tools.js'; +import { + normalizeToolFilePath, + ToolAccesses, + type ToolAccesses as AccessSet, +} from '../tool-access.js'; +import type { MakaTool, MakaToolAccessContext } from '../tool-runtime.js'; + +describe('builtin tool access declarations', () => { + const cwd = resolve('workspace'); + const tools = new Map(buildBuiltinTools().map((tool) => [tool.name, tool])); + const expectedPath = (path: string) => normalizeToolFilePath(path, { cwd }); + + test('maps file reads and writes to their concrete targets', async () => { + assert.deepEqual(await accesses(tools, 'Read', { path: 'a.ts' }, cwd), [ + { kind: 'file', operation: 'read', path: expectedPath('a.ts') }, + ]); + assert.deepEqual(await accesses(tools, 'Write', { path: 'a.ts', content: 'x' }, cwd), [ + { kind: 'file', operation: 'write', path: expectedPath('a.ts') }, + ]); + for (const [name, input] of [ + ['Edit', { path: 'a.ts', old_string: 'a', new_string: 'b' }], + ['FormatJson', { path: 'a.json' }], + ] as const) { + assert.deepEqual(await accesses(tools, name, input, cwd), [ + { + kind: 'file', + operation: 'readwrite', + path: expectedPath(input.path), + }, + ]); + } + }); + + test('keeps runtime-resource reads fail-closed', async () => { + assert.deepEqual( + await accesses(tools, 'Read', { ref: 'runtime://resource' }, cwd), + ToolAccesses.all(), + ); + }); + + test('maps Glob and Grep to recursive search roots', async () => { + assert.deepEqual(await accesses(tools, 'Glob', { pattern: '**/*.ts', cwd: 'src' }, cwd), [ + { + kind: 'file', + operation: 'search', + path: expectedPath('src'), + recursive: true, + }, + ]); + assert.deepEqual(await accesses(tools, 'Grep', { pattern: 'TODO' }, cwd), [ + { + kind: 'file', + operation: 'search', + path: expectedPath('.'), + recursive: true, + }, + ]); + }); + + test('declares every valid apply_patch target as one atomic access set', async () => { + const patch = [ + '*** Begin Patch', + '*** Add File: added.txt', + '+added', + '*** Update File: changed.txt', + '@@', + '-before', + '+after', + '*** End Patch', + ].join('\n'); + assert.deepEqual(await accesses(tools, 'apply_patch', patch, cwd), [ + { kind: 'file', operation: 'write', path: expectedPath('added.txt') }, + { kind: 'file', operation: 'write', path: expectedPath('changed.txt') }, + ]); + }); +}); + +async function accesses( + tools: ReadonlyMap, + name: string, + input: unknown, + cwd: string, +): Promise { + const tool = tools.get(name); + if (!tool?.resolveAccesses) throw new Error(`${name} has no access declaration`); + const context: MakaToolAccessContext = { + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + cwd, + permissionMode: 'ask', + toolCallId: `${name}-call`, + abortSignal: new AbortController().signal, + }; + return (await tool.resolveAccesses(input, context)) ?? ToolAccesses.all(); +} 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-call-batch.test.ts b/packages/runtime/src/__tests__/tool-call-batch.test.ts new file mode 100644 index 0000000000..a25df15982 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-call-batch.test.ts @@ -0,0 +1,212 @@ +/* + * 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 { ToolAccesses } from '../tool-access.js'; +import { settleToolCallBatch } from '../tool-call-batch.js'; + +const POSIX = { cwd: '/repo', platform: 'linux' as const }; + +describe('settleToolCallBatch', () => { + test('waits for every access plan 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( + [ + { + id: 'first', + resolveAccesses: async () => { + await preparation.promise; + return ToolAccesses.writeFile('/repo/a', POSIX); + }, + run: () => { + starts.push('first'); + return first.promise; + }, + }, + { + id: 'second', + resolveAccesses: () => ToolAccesses.writeFile('/repo/b', POSIX), + run: () => { + starts.push('second'); + bothStarted.resolve(); + return second.promise; + }, + }, + ], + POSIX, + ); + + 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) => ({ + id: String(index), + resolveAccesses: () => ToolAccesses.none(), + run: () => gate.promise, + })), + POSIX, + ); + + 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('fails closed to all when an access declaration is absent or throws', async () => { + for (const resolveAccesses of [ + undefined, + () => { + throw new Error('bad declaration'); + }, + ]) { + const first = deferred(); + const unknownStarted = deferred(); + const starts: string[] = []; + const batch = settleToolCallBatch( + [ + { + id: 'unknown', + ...(resolveAccesses ? { resolveAccesses } : {}), + run: () => { + starts.push('unknown'); + unknownStarted.resolve(); + return first.promise; + }, + }, + { + id: 'writer', + resolveAccesses: () => ToolAccesses.writeFile('/repo/a', POSIX), + run: () => { + starts.push('writer'); + return undefined; + }, + }, + ], + POSIX, + ); + + await unknownStarted.promise; + assert.deepEqual(starts, ['unknown']); + first.resolve(); + await batch; + assert.deepEqual(starts, ['unknown', 'writer']); + } + }); + + test('does not start a queued task cancelled during preparation', async () => { + const preparation = deferred(); + const controller = new AbortController(); + let starts = 0; + const batch = settleToolCallBatch( + [ + { + id: 'preparing', + resolveAccesses: async () => { + await preparation.promise; + return ToolAccesses.none(); + }, + run: () => 'ok', + }, + { + id: 'cancelled', + signal: controller.signal, + resolveAccesses: () => ToolAccesses.none(), + run: () => { + starts += 1; + return 'should not run'; + }, + }, + ], + POSIX, + ); + + 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('abort releases a batch whose access planner never settles', async () => { + const controller = new AbortController(); + let starts = 0; + const batch = settleToolCallBatch( + [ + { + id: 'hung-planner', + signal: controller.signal, + resolveAccesses: () => new Promise(() => {}), + run: () => { + starts += 1; + }, + }, + ], + POSIX, + ); + + controller.abort(new Error('stop planning')); + const outcomes = await batch; + assert.equal(starts, 0); + assert.equal(outcomes[0]?.status, 'rejected'); + }); +}); + +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-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 2540f554b2..e625722972 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -639,6 +639,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..9cbc08e599 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-scheduler.test.ts @@ -0,0 +1,275 @@ +/* + * 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 { ToolAccesses } from '../tool-access.js'; +import { ToolScheduler } from '../tool-scheduler.js'; + +const POSIX = { cwd: '/repo', platform: 'linux' as const }; + +describe('ToolScheduler', () => { + test('starts non-conflicting 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, + accesses: ToolAccesses.readFile('/repo/a', POSIX), + run: () => { + started.push('first'); + return first.promise; + }, + }); + const secondResult = scheduler.add({ + id: 'read-a-2', + sequence: 1, + accesses: ToolAccesses.readFile('/repo/a', POSIX), + run: () => { + started.push('second'); + return second.promise; + }, + }); + + 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, + accesses: ReturnType, + gate: ReturnType>, + ) => + scheduler.add({ + id, + sequence, + accesses, + run: () => { + started.push(id); + return gate.promise; + }, + }); + + const results = [ + task('reader-1', 0, ToolAccesses.readFile('/repo/a', POSIX), reader), + task('writer', 1, ToolAccesses.writeFile('/repo/a', POSIX), writer), + task('reader-2', 2, ToolAccesses.readFile('/repo/a', POSIX), laterReader), + task('independent', 3, ToolAccesses.writeFile('/repo/b', POSIX), 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, + accesses: ToolAccesses.all(), + run: () => { + started.push('all'); + return blocker.promise; + }, + }), + scheduler.add({ + id: 'a', + sequence: 1, + accesses: ToolAccesses.writeFile('/repo/a', POSIX), + run: () => { + started.push('a'); + return a.promise; + }, + }), + scheduler.add({ + id: 'b', + sequence: 2, + accesses: ToolAccesses.writeFile('/repo/b', POSIX), + run: () => { + started.push('b'); + return b.promise; + }, + }), + ]; + + assert.deepEqual(started, ['all']); + blocker.resolve(); + await flushMicrotasks(); + assert.deepEqual(started, ['all', 'a', 'b']); + a.resolve(); + b.resolve(); + await Promise.all(results); + }); + + test('releases resources after asynchronous rejection and synchronous throw', async () => { + for (const firstRun of [ + () => Promise.reject(new Error('async failure')), + () => { + throw new Error('sync failure'); + }, + ]) { + const scheduler = new ToolScheduler(); + const started: string[] = []; + const first = scheduler.add({ + id: 'first', + sequence: 0, + accesses: ToolAccesses.writeFile('/repo/a', POSIX), + run: firstRun, + }); + const second = scheduler.add({ + id: 'second', + sequence: 1, + accesses: ToolAccesses.readFile('/repo/a', POSIX), + run: () => { + started.push('second'); + return 'ok'; + }, + }); + + await assert.rejects(first, /failure/); + assert.equal(await second, 'ok'); + assert.deepEqual(started, ['second']); + } + }); + + 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, + accesses: ToolAccesses.writeFile('/repo/a', POSIX), + run: () => active.promise, + }); + const queuedResult = scheduler.add({ + id: 'queued', + sequence: 1, + accesses: ToolAccesses.writeFile('/repo/a', POSIX), + signal: controller.signal, + run: () => { + queuedStarts += 1; + }, + }); + 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('releases active work after the tool observes cancellation', async () => { + const scheduler = new ToolScheduler(); + const controller = new AbortController(); + const started: string[] = []; + const active = scheduler.add({ + id: 'active', + sequence: 0, + accesses: ToolAccesses.writeFile('/repo/a', POSIX), + signal: controller.signal, + run: () => + new Promise((_resolve, reject) => { + controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { + once: true, + }); + }), + }); + const next = scheduler.add({ + id: 'next', + sequence: 1, + accesses: ToolAccesses.readFile('/repo/a', POSIX), + run: () => { + started.push('next'); + return 'done'; + }, + }); + + controller.abort(new Error('cancel active')); + await assert.rejects(active, /cancel active/); + assert.equal(await next, 'done'); + assert.deepEqual(started, ['next']); + }); + + test('rejects duplicate or out-of-order sequence submission', () => { + const scheduler = new ToolScheduler(); + void scheduler.add({ + id: 'first', + sequence: 1, + accesses: ToolAccesses.none(), + run: () => undefined, + }); + assert.throws( + () => + scheduler.add({ + id: 'duplicate', + sequence: 1, + accesses: ToolAccesses.none(), + run: () => undefined, + }), + /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/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index 18ddde6aff..8862c77d8a 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -93,6 +93,7 @@ import { sandboxBoundaryExpansionSchema, selectedBashBoundaryExpansion, } from './sandbox-boundary-declaration.js'; +import { ToolAccesses, type ToolResourceAccess } from './tool-access.js'; // Generous wall-clock cap for the ripgrep-backed Grep tool. A search should be // near-instant; this only bounds a pathological hang now that the stream @@ -333,6 +334,19 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT parameters: openAiApplyPatchInputSchema, providerTool: { kind: 'openai-apply-patch' }, executionFacts, + resolveAccesses: (input, ctx) => { + const operations = + typeof input === 'string' + ? parseCodexV4aPatch(input) + : input && typeof input === 'object' && 'operation' in input + ? [input.operation] + : undefined; + if (!operations) return ToolAccesses.all(); + return operations.map( + (operation): ToolResourceAccess => + ToolAccesses.writeFile(operation.path, { cwd: ctx.cwd })[0]!, + ); + }, impl: async (input, ctx) => { if (typeof input !== 'string') { return await filesystem.applyPatch({ operation: input.operation, ...filesystemCall(ctx) }); @@ -385,6 +399,10 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT }, } : {}), + resolveAccesses: (input, ctx) => + input && typeof input === 'object' && 'path' in input && typeof input.path === 'string' + ? ToolAccesses.readFile(input.path, { cwd: ctx.cwd }) + : ToolAccesses.all(), impl: async (input, ctx) => { const { cwd, sessionId, abortSignal } = ctx; if ('ref' in input) { @@ -459,6 +477,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT content: z.string(), }), executionFacts, + resolveAccesses: ({ path }, ctx) => ToolAccesses.writeFile(path, { cwd: ctx.cwd }), impl: async ({ path, content }, ctx) => { const result = await filesystem.execute({ operation: { kind: 'write', path, content }, @@ -487,6 +506,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT new_string: z.string(), }), executionFacts, + resolveAccesses: ({ path }, ctx) => ToolAccesses.readWriteFile(path, { cwd: ctx.cwd }), impl: async ({ path, old_string, new_string }, ctx) => { const result = await filesystem.execute({ operation: { @@ -538,6 +558,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT .describe('Sort object keys lexicographically; default false.'), }), executionFacts, + resolveAccesses: ({ path }, ctx) => ToolAccesses.readWriteFile(path, { cwd: ctx.cwd }), impl: async ({ path, sort_keys }, ctx) => { const result = await filesystem.execute({ operation: { @@ -578,6 +599,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ), }), executionFacts, + resolveAccesses: ({ cwd }, ctx) => ToolAccesses.searchTree(cwd ?? '.', { cwd: ctx.cwd }), impl: async ({ pattern, cwd: relCwd }, ctx) => { const result = await filesystem.execute({ operation: { kind: 'glob', path: relCwd ?? '.', pattern, limit: 200 }, @@ -602,6 +624,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT glob: z.string().optional(), }), executionFacts, + resolveAccesses: ({ path }, ctx) => ToolAccesses.searchTree(path ?? '.', { cwd: ctx.cwd }), 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 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..d3856e87ca --- /dev/null +++ b/packages/runtime/src/tool-call-batch.ts @@ -0,0 +1,115 @@ +/* + * 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 { + normalizeToolAccesses, + ToolAccesses, + type NormalizeToolAccessOptions, + type ToolAccesses as ToolAccessSet, +} from './tool-access.js'; +import { ToolScheduler } from './tool-scheduler.js'; + +export interface ToolCallBatchEntry { + readonly id: string; + readonly signal?: AbortSignal; + /** Omission is fail-closed and becomes ToolAccesses.all(). */ + readonly resolveAccesses?: () => Promise | ToolAccessSet | undefined; + readonly run: () => Promise | Result; +} + +/** + * Prepare every call behind one barrier, submit by original array index, and + * return settled outcomes in that same order regardless of completion order. + */ +export async function settleToolCallBatch( + entries: readonly ToolCallBatchEntry[], + normalizeOptions: NormalizeToolAccessOptions = {}, +): Promise[]> { + const slots = entries.map((entry, index) => ({ entry, index, sequence: index })); + const prepared = await Promise.all( + slots.map(async (slot) => ({ + ...slot, + accesses: await resolveEntryAccesses(slot.entry, normalizeOptions), + })), + ); + + const scheduler = new ToolScheduler(); + const resultSlots = prepared.map(({ entry, sequence, accesses }) => + scheduler.add({ + id: entry.id, + sequence, + accesses, + ...(entry.signal ? { signal: entry.signal } : {}), + run: entry.run, + }), + ); + return await Promise.allSettled(resultSlots); +} + +async function resolveEntryAccesses( + entry: ToolCallBatchEntry, + options: NormalizeToolAccessOptions, +): Promise { + if (!entry.resolveAccesses) return ToolAccesses.all(); + try { + const declared = await resolveAccessesUntilAbort(entry); + return normalizeToolAccesses(declared ?? ToolAccesses.all(), options); + } catch { + // Access planning is a concurrency optimization and must never widen the + // set of operations allowed by ToolRuntime. A bad declaration therefore + // fails closed to global serialization while Runtime still owns the actual + // validation and model-visible error. + return ToolAccesses.all(); + } +} + +function resolveAccessesUntilAbort( + entry: ToolCallBatchEntry, +): Promise { + const planning = Promise.resolve().then(() => entry.resolveAccesses?.()); + if (!entry.signal) return planning; + if (entry.signal.aborted) return Promise.reject(abortReason(entry.signal, entry.id)); + + return new Promise((resolve, reject) => { + const onAbort = () => { + cleanup(); + reject(abortReason(entry.signal!, entry.id)); + }; + const cleanup = () => entry.signal?.removeEventListener('abort', onAbort); + entry.signal!.addEventListener('abort', onAbort, { once: true }); + planning.then( + (accesses) => { + cleanup(); + resolve(accesses); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + if (entry.signal!.aborted) onAbort(); + }); +} + +function abortReason(signal: AbortSignal, entryId: string): unknown { + if (signal.reason !== undefined) return signal.reason; + return Object.assign(new Error(`Tool call ${entryId} was cancelled during access planning`), { + name: 'AbortError', + }); +} diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a12fdce73e..04082b4e91 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -136,6 +136,11 @@ 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 interface ResolvedMakaToolCall { tool: MakaTool; @@ -150,6 +155,8 @@ export interface ResolvedMakaToolCall { parentToolCallId?: string; parentOperationId?: string; maxResultBytes?: number; + /** Precomputed provider-batch admission; nested/direct calls omit it. */ + stepAdmission?: ToolStepAdmission; } export interface DurableSessionEventSink { @@ -174,6 +181,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 +228,15 @@ 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. + */ + 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. */ @@ -860,6 +881,7 @@ export class ToolRuntime { ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), }, call.stepId, + call.stepAdmission, ); const providerError = providerToolErrorMessage(result); return { result, ...(providerError ? { providerError } : {}) }; @@ -1117,6 +1139,7 @@ export class ToolRuntime { maxResultBytes?: number; }, stepId?: string, + stepAdmission?: ToolStepAdmission, ): Promise { const rawExecutionArgs = snapshotToolArgs(args); const sandboxBoundaryDecisionGeneration = this.sandboxBoundaryDecisionGeneration; @@ -1127,7 +1150,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; @@ -2463,6 +2492,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..a2c62218bc --- /dev/null +++ b/packages/runtime/src/tool-scheduler.ts @@ -0,0 +1,201 @@ +/* + * 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 { toolAccessesConflict, type ToolAccesses } from './tool-access.js'; + +export interface ToolSchedulerTask { + readonly id: string; + readonly sequence: number; + readonly accesses: ToolAccesses; + readonly signal?: AbortSignal; + readonly run: () => 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. + */ +export class ToolScheduler { + private readonly activeTasks: ScheduledTask[] = []; + private queuedTasks: ScheduledTask[] = []; + private lastSequence = -1; + + 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 (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) => toolAccessesConflict(task.accesses, candidate.accesses)); + } + + private startTask(task: ScheduledTask): void { + 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()); + } 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 task.reject(outcome.reason); + this.drainQueue(); + } + + private drainQueue(): void { + 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/tool-runtime-task-scheduler-architecture.md b/tool-runtime-task-scheduler-architecture.md new file mode 100644 index 0000000000..891192979e --- /dev/null +++ b/tool-runtime-task-scheduler-architecture.md @@ -0,0 +1,978 @@ + + +# ToolRuntime 基于 Task 与 Access 的资源感知调度架构 + +## 1. 文档状态 + +- 状态:架构设计提案 +- 范围:同一个 assistant step 返回的本地 Tool Call batch +- 目标:在保留无冲突工具并发能力的同时,避免共享资源上的读写竞态,并确保 Tool Result 按原始 Tool Call 顺序返回 +- 关联 Issue:[`apache/maka#4487`](https://github.com/apache/maka/issues/4487) + +## 2. 背景 + +当前 Runtime 使用 `Promise.allSettled()` 并发消费一个 assistant step 中的本地 Tool Call: + +```text +ToolCalls + ↓ +returnedToolCalls.map(settleToolCall) + ↓ +Promise.allSettled + ↓ +下一次 LLM step +``` + +该模型能够实现 fan-out/fan-in,但除 `exclusive_step` 外,Runtime 不理解不同调用访问的共享资源。两个不依赖彼此返回值的 Tool Call,仍可能竞争同一文件、Session 状态、终端、浏览器标签页或远端服务。 + +本方案把每个 Tool Call 封装成一个 `ToolCallTask`。工具负责根据本次调用参数声明 `accesses`,Scheduler 根据资源冲突关系决定 Task 的启动时间,Batch Runner 最后按 Tool Call 原始顺序组装 Tool Results。 + +## 3. 设计目标 + +1. 每个 Tool Call 对应一个可独立调度、且最多启动一次的 Task。 +2. 资源不冲突的 Task 尽早并发执行。 +3. 资源冲突的 Task 按模型生成顺序执行。 +4. 后到的非冲突 Task 可以越过正在等待的 Task。 +5. 防止持续到来的只读 Task 越过已经排队的写 Task。 +6. Task 的调度顺序、完成顺序和 Tool Result 返回顺序相互解耦。 +7. 工具未声明资源范围时采用保守的 fail-closed 策略。 +8. 保留现有 durable settlement、事件发布、取消和 Turn 级错误边界。 + +## 4. 非目标 + +第一阶段不解决以下问题: + +- 不推导 Bash 命令的精确副作用集合。 +- 不保证不同 Turn、Session、Agent 或 Runtime 进程之间的资源互斥。 +- 不为整个 batch 构建静态 DAG。 +- 不改变模型侧 Tool Call/Tool Result 协议。 +- 不用 Scheduler 替代参数校验、权限判断、sandbox 或持久化逻辑。 + +## 5. 核心设计决策 + +### 5.1 Access 是调用级数据 + +Access 由工具根据本次 Tool Call 的参数生成,而不是 Tool 的静态属性,也不由模型直接生成。 + +```text +Write({ path: "a.ts" }) → writeFile(a.ts) +Write({ path: "b.ts" }) → writeFile(b.ts) +``` + +### 5.2 一个 Task 包含多个 Access + +字段使用 `accesses` 而不是单数 `access`。一个 Task 可能同时访问多个资源,例如: + +```text +Copy(source, target) + → read(source) + → write(target) +``` + +Task 只有在全部 accesses 都可用时才能启动,不允许先占用部分资源、再等待其余资源。 + +### 5.3 Scheduler 不理解具体 Tool + +领域层 Task 可以保存 Tool 对象用于执行和诊断,但 Scheduler 只能依赖: + +- Task 顺序号; +- 标准化后的 accesses; +- 唯一的执行入口 `run()`。 + +Scheduler 不允许根据 `tool.name` 或原始参数编写特殊分支。 + +### 5.4 未声明 Access 时默认 `all` + +只有能够证明资源范围的工具才能声明精确 access;只有能够证明不访问 Scheduler 管理资源的工具才能声明 `none`。 + +```text +显式声明 accesses → 使用声明值 +未声明 accesses → all +不执行真实副作用 → none +``` + +### 5.5 `exclusive_step` 独立于资源调度 + +`exclusive_step` 是控制面和因果边界;`all` 是数据面资源互斥: + +- `all` 可以在当前 batch 内等待并执行; +- `exclusive_step` 要求独占 assistant step,冲突调用应保持现有 admission rejection/synthetic result 语义。 + +因此不能把 `exclusive_step` 简化为 `accesses: all()`。 + +## 6. 总体架构 + +```text +LLM 返回有序 ToolCalls + │ + ▼ +┌─────────────────────────────┐ +│ ToolCallBatchRunner │ +│ 分配固定 index/sequence │ +└──────────────┬──────────────┘ + ▼ +┌─────────────────────────────┐ +│ BatchAdmission │ +│ exclusive_step / step 边界 │ +└──────────────┬──────────────┘ + ▼ +┌─────────────────────────────┐ +│ ToolTaskFactory │ +│ 查找 Tool │ +│ 校验并规范化参数 │ +│ resolveExecution() │ +│ 生成 accesses 与 run() │ +└──────────────┬──────────────┘ + ▼ +┌─────────────────────────────┐ +│ ToolScheduler │ +│ 无冲突 Task 并发 │ +│ 冲突 Task 按 sequence 排队 │ +└──────────────┬──────────────┘ + ▼ + Task 可以乱序完成 + │ + ▼ +┌─────────────────────────────┐ +│ ToolResultAssembler │ +│ 按原始 index 回填结果 │ +└──────────────┬──────────────┘ + ▼ + 有序 ToolResults[] +``` + +### 6.1 对象调用图 + +下面的时序图描述新方案中各对象的调用关系。实线表示调用,虚线表示返回;Task 的实际完成顺序可以与提交顺序不同。 + +```mermaid +sequenceDiagram + autonumber + participant Provider as Model Provider + participant Runner as ToolCallBatchRunner + participant Admission as BatchAdmission + participant Factory as ToolTaskFactory + participant Registry as ToolRegistry + participant Tool as MakaTool + participant Scheduler as ToolScheduler + participant Task as ToolCallTask + participant Runtime as ToolRuntime + participant Impl as Tool Implementation + participant Assembler as ToolResultAssembler + participant NextStep as Next LLM Step + + Provider->>Runner: consume(toolCalls, turnContext) + Runner->>Runner: assign index and sequence before async work + Runner->>Admission: admit(toolCalls, stepState) + Admission-->>Runner: admitted calls and synthetic result slots + + loop Each admitted Tool Call + Runner->>Factory: prepare(slot, turnContext) + Factory->>Registry: get(toolCall.toolName) + Registry-->>Factory: MakaTool + Factory->>Tool: resolveExecution(input, toolContext) + Tool-->>Factory: ToolExecution(accesses?, execute) + Factory->>Factory: normalize accesses or default to all() + Factory-->>Runner: ToolCallTask(sequence, accesses, run) + end + + Note over Runner: Preparation barrier preserves original submission order + + loop Submit tasks by sequence + Runner->>Scheduler: add(task) + alt Conflicts with active or earlier queued task + Scheduler->>Scheduler: enqueue(task) + Scheduler-->>Runner: pending result Promise + else Runnable now + Scheduler->>Scheduler: mark task active + Scheduler->>Task: run(abortSignal) asynchronously + Scheduler-->>Runner: pending result Promise + end + end + + Task->>Runtime: settleToolCall(tool, input, context) + Runtime->>Runtime: admission, validation, permission, T1 dispatch + Runtime->>Impl: tool.impl(input, toolContext) + Impl-->>Runtime: raw result or business error + Runtime->>Runtime: normalize result and persist T2 outcome + Runtime-->>Task: ToolSettlement + Task-->>Scheduler: result settled + Scheduler->>Scheduler: finishTask() and release accesses + Scheduler->>Scheduler: drainQueue() from front to back + Scheduler->>Task: run() for newly unblocked tasks + + Runner->>Runner: await Promise.allSettled(result slots) + Runner->>Assembler: assemble(toolCalls, outcomes) + Assembler->>Assembler: pair by index and toolCallId + Assembler-->>Runner: ordered ToolResults[] + Runner->>NextStep: continue with complete batch results +``` + +关键对象调用链: + +```text +ToolCallBatchRunner + → BatchAdmission + → ToolTaskFactory + → ToolRegistry + → MakaTool.resolveExecution() + → ToolScheduler.add() + → ToolCallTask.run() + → ToolRuntime.settleToolCall() + → Tool implementation + → finishTask() + → drainQueue() + → ToolResultAssembler.assemble() + → Next LLM Step +``` + +其中,`ToolScheduler.add()` 返回的是与原始 result slot 绑定的 Promise。Scheduler 只控制 `run()` 何时被调用;`ToolResultAssembler` 不读取 Scheduler 的完成顺序,只按照预先分配的 `index/sequence` 回填结果。 + +## 7. 组件职责 + +### 7.1 ToolCallBatchRunner + +负责一个 Tool Call batch 的完整生命周期: + +1. 保存 Provider 返回的 Tool Call 顺序。 +2. 在任何异步工作之前分配 `index` 和 `sequence`。 +3. 执行 step admission。 +4. 调用 `ToolTaskFactory` 准备 Task。 +5. 按原始顺序向 Scheduler 提交 Task。 +6. 等待整批 Task settle。 +7. 调用 `ToolResultAssembler` 生成有序结果。 + +### 7.2 ToolTaskFactory + +负责把一个具体 Tool Call 转换成执行计划: + +1. 根据名称查找 Tool。 +2. 校验和解析参数。 +3. 把路径、Session ID、Tab ID 等转换为稳定资源标识。 +4. 调用 Tool 的 `resolveExecution()`。 +5. 对未声明的 accesses 补充 `all()`。 +6. 构造只允许启动一次的 `run()`。 +7. 对工具不存在、参数错误、hook 阻止等情况创建 resolved Task。 + +### 7.3 ToolScheduler + +只回答一个问题:一个已经准备完成的 Task 现在能否启动? + +它不负责: + +- Tool 参数校验; +- 权限审批; +- sandbox; +- Tool Result 格式化; +- RuntimeEvent 持久化; +- Tool Result 的最终返回顺序。 + +### 7.4 ToolRuntime + +保留单次调用的可靠执行边界: + +```text +run() + → executeTool()/settleToolCall() + → 权限与可用性检查 + → T1 durable dispatch + → tool_start + → tool.impl() + → 结果归一化 + → T2 durable outcome + → tool_result + → ToolSettlement +``` + +Scheduler 调度的是整个 settlement,而不是绕过 ToolRuntime 直接调用 `tool.impl()`。 + +### 7.5 ToolResultAssembler + +负责把 Task outcome 转换成模型协议需要的 Tool Result,并保证: + +- 每个原始 Tool Call 恰好对应一个结果槽位; +- 最终数组按原始 Tool Call 顺序排列; +- Tool Call ID 与 Tool Result ID 正确配对; +- 基础设施级失败不会被误包装成普通业务错误。 + +## 8. 领域模型 + +### 8.1 ToolCallTask + +```ts +interface ToolCallTask { + readonly id: string; + readonly sequence: number; + readonly toolCall: ToolCallPart; + readonly tool: MakaTool; + readonly input: unknown; + readonly accesses: ToolAccesses; + readonly run: (signal: AbortSignal) => Promise; +} +``` + +`tool` 可以被 Task 保存,但 Scheduler 不直接读取它。 + +### 8.2 SchedulerTask + +```ts +interface SchedulerTask { + readonly id: string; + readonly sequence: number; + readonly accesses: ToolAccesses; + readonly run: () => Promise; +} + +interface ScheduledTask extends SchedulerTask { + state: "queued" | "active" | "finished"; + readonly result: Promise; +} +``` + +合法状态转换: + +```text +new → queued → active → finished +new → active → finished +``` + +禁止: + +- `active → queued`; +- `finished → active`; +- 同一个 Task 同时存在于 active 和 queued; +- 同一个 Task 多次调用 `run()`。 + +## 9. Resource Access 模型 + +### 9.1 类型定义 + +```ts +type ToolAccesses = readonly ResourceAccess[]; + +type ResourceAccess = + | { + readonly kind: "file"; + readonly path: string; + readonly operation: "read" | "search" | "write" | "readwrite"; + readonly recursive?: boolean; + } + | { + readonly kind: "key"; + readonly key: string; + readonly operation: "read" | "write"; + } + | { + readonly kind: "all"; + }; +``` + +### 9.2 特殊集合 + +```ts +ToolAccesses.none() // [] +ToolAccesses.all() // [{ kind: "all" }] +``` + +- `none`:不访问当前 Scheduler 建模的共享资源。 +- `all`:资源范围未知,与任意非空 accesses 冲突。 +- `all` 与 `none` 不冲突,因为 `none` 不占用资源。 + +`none` 不等于“工具没有任何外部副作用”,只表示它不访问当前 Scheduler 管理的资源。Web 请求的连接数、QPS 和预算应由独立的容量控制处理。 + +### 9.3 文件路径规范化 + +文件 access 进入 Scheduler 前必须完成: + +- 转换为绝对路径; +- 消解 `.` 和 `..`; +- 统一路径分隔符; +- 去除无意义的尾部分隔符; +- 按平台决定大小写敏感性; +- 明确单文件或递归目录范围。 + +Scheduler 的冲突判断不得执行文件系统 I/O。符号链接和 junction 如需归并,应在 Task 准备阶段生成 canonical resource identity。 + +### 9.4 逻辑资源 Key + +非文件资源使用带命名空间的稳定 key: + +```text +session:{sessionId}:todo +session:{sessionId}:goal +execution:{executionId}:plan +terminal:{sessionId}:{ref} +browser:{browserSessionId}:tab:{tabId} +computer:{deviceId}:window:{windowId} +mcp:{serverId}:session:{sessionId} +``` + +Key 的生成属于 ToolTaskFactory 或具体 Tool,不属于 Scheduler。 + +## 10. 冲突模型 + +### 10.1 Task 级冲突 + +两个 Task 的 accesses 做笛卡尔积比较,只要存在一对资源 access 冲突,两个 Task 就冲突: + +```ts +function tasksConflict(left: ToolAccesses, right: ToolAccesses): boolean { + return left.some(a => right.some(b => accessesConflict(a, b))); +} +``` + +### 10.2 读写冲突 + +| 左 / 右 | read | search | write | readwrite | +|---|---:|---:|---:|---:| +| read | 否 | 否 | 是 | 是 | +| search | 否 | 否 | 是 | 是 | +| write | 是 | 是 | 是 | 是 | +| readwrite | 是 | 是 | 是 | 是 | + +只有操作类型可能冲突且资源范围重叠时,才构成实际冲突。 + +### 10.3 文件范围重叠 + +以下任一条件成立即为重叠: + +1. 两个标准化路径完全相同。 +2. 左侧为递归访问,右侧位于左侧目录树内。 +3. 右侧为递归访问,左侧位于右侧目录树内。 + +父子关系必须按照路径分段判断: + +```text +/repo/src 是 /repo/src/a.ts 的父目录 +/repo/src 不是 /repo/src2/a.ts 的父目录 +``` + +冲突函数必须满足对称性: + +```text +conflict(A, B) == conflict(B, A) +``` + +## 11. Access 生成协议 + +建议扩展 Tool contract: + +```ts +interface ToolExecution { + readonly accesses?: ToolAccesses; + readonly execute: () => Promise; +} + +interface MakaTool { + resolveExecution( + input: Input, + context: ToolContext, + ): ToolExecution | Promise>; +} +``` + +Task Factory 的兜底规则: + +```ts +const execution = await tool.resolveExecution(input, context); +const accesses = execution.accesses ?? ToolAccesses.all(); +``` + +工具不存在、参数校验失败、被 hook 阻止、admission 拒绝或已经产生 synthetic result 时,不会执行真实副作用,应创建 `none()` Task 或直接创建 resolved result slot。 + +## 12. 推荐的工具映射 + +| 工具类别 | 建议 Access | +|---|---| +| `Read` / `ReadMediaFile` | `readFile(resolvedPath)` | +| `Write` | `writeFile(resolvedPath)` | +| `Edit` / `FormatJson` | `readWriteFile(resolvedPath)` | +| `Glob` / `Grep` | `searchTree(resolvedRootOrWorkspace)` | +| `apply_patch` | 补丁涉及的所有文件 `writeFile` | +| `Bash` | 默认 `all()`;后续允许调用方声明精确资源 | +| `todo_read` | `read(session:{id}:todo)` | +| `todo_write` | `write(session:{id}:todo)` | +| Goal 查询 | `read(session:{id}:goal)` | +| Goal 修改 | `write(session:{id}:goal)` | +| Plan 查询 | `read(execution:{id}:plan)` | +| Plan 修改 | `write(execution:{id}:plan)` | +| Terminal mutation | `write(terminal:{sessionId}:{ref})` | +| Browser mutation | `write(browser:{sessionId}:tab:{tabId})` | +| Computer mutation | `write(computer:{deviceId}:window:{windowId})` | +| WebSearch / WebFetch | `none()`,另设 provider 容量限制 | +| MCP read-only | server 容量限制内的 read key | +| MCP unknown/mutation | server/session/resource write key,无法确定则 `all()` | +| synthetic result | `none()` | + +## 13. Scheduler 算法 + +### 13.1 状态 + +```ts +activeTasks: ScheduledTask[]; +queuedTasks: ScheduledTask[]; +nextSequence: number; +``` + +### 13.2 阻塞条件 + +```ts +function isBlocked( + task: ScheduledTask, + active: readonly ScheduledTask[], + queuedBefore: readonly ScheduledTask[], +): boolean { + return ( + conflictsWithAny(task, active) || + conflictsWithAny(task, queuedBefore) + ); +} +``` + +检查 active 保证资源安全;检查前序 queued 保证冲突顺序和 writer 公平性。 + +示例: + +```text +active: R1 = read(a) +queued: W = write(a) +new: R2 = read(a) +``` + +虽然 R2 不与 R1 冲突,但它与更早排队的 W 冲突,所以 R2 必须排在 W 后面。否则持续到来的 reader 会导致 writer starvation。 + +### 13.3 添加 Task + +```ts +function add(task: SchedulerTask): Promise { + const scheduled = createScheduledTask(task); + + if (isBlocked(scheduled, activeTasks, queuedTasks)) { + queuedTasks.push(scheduled); + } else { + startTask(scheduled); + } + + return scheduled.result; +} +``` + +新 Task 可以越过前序 queued Task,但前提是二者不存在资源冲突。 + +### 13.4 启动 Task + +启动前必须先将 Task 放入 active,确保同步到来的下一次 `add()` 能观察到资源已经被占用: + +```ts +function startTask(task: ScheduledTask): void { + assert(task.state === "queued"); + task.state = "active"; + activeTasks.push(task); + + let started: Promise; + try { + started = Promise.resolve(task.run()); + } catch (error) { + started = Promise.reject(error); + } + + void started + .then(task.resolve, task.reject) + .finally(() => finishTask(task)); +} +``` + +同步抛错也必须进入统一的异步完成路径,避免同步重入导致队列状态损坏。 + +### 13.5 完成和重扫 + +```ts +function finishTask(task: ScheduledTask): void { + if (task.state !== "active") return; + + remove(activeTasks, task); + task.state = "finished"; + drainQueue(); +} +``` + +队列从前向后重扫: + +```ts +function drainQueue(): void { + const stillQueued: ScheduledTask[] = []; + + for (const task of queuedTasks) { + if (isBlocked(task, activeTasks, stillQueued)) { + stillQueued.push(task); + } else { + startTask(task); + } + } + + queuedTasks = stillQueued; +} +``` + +一次重扫可以启动多个互不冲突的 Task,不应只消费队头一个 Task。 + +### 13.6 调度示例 + +按顺序提交: + +```text +T1 = read(a) +T2 = write(a) +T3 = read(a) +T4 = write(b) +``` + +提交后: + +```text +T1:启动 +T2:与 T1 冲突,排队 +T3:与前序 queued T2 冲突,排队 +T4:与 active 和 queued 均不冲突,启动 + +active = [T1, T4] +queued = [T2, T3] +``` + +T1 完成后: + +```text +T2:启动 +T3:与刚启动的 T2 冲突,继续等待 +``` + +T2 完成后,T3 启动。 + +## 14. 顺序保证 + +必须区分三种顺序: + +```text +模型生成顺序 ≠ Task 完成顺序 ≠ 实时事件顺序 +``` + +### 14.1 Sequence 分配 + +`sequence` 必须在任何异步准备工作之前,按照 Provider 返回数组的 index 分配: + +```ts +const slots = toolCalls.map((toolCall, index) => ({ + index, + sequence: index, + toolCall, +})); +``` + +如果 `resolveExecution()` 是异步的,不能按照“准备完成顺序”提交 Scheduler,否则资源冲突 Task 的先后关系会偏离模型生成顺序。 + +第一版采用 preparation barrier: + +1. 先创建所有有固定 index 的 slot。 +2. 可以并发准备 execution plan。 +3. 等所有 plan 准备完成。 +4. 严格按 index 调用 `scheduler.add()`。 + +### 14.2 Result Slot + +每个原始 Tool Call 始终保留一个 pending result slot: + +```ts +const pendingResults = preparedSlots.map(slot => { + if (slot.syntheticResult) { + return Promise.resolve(slot.syntheticResult); + } + + return scheduler.add(slot.task); +}); +``` + +### 14.3 有序组装 + +```ts +const outcomes = await Promise.allSettled(pendingResults); + +const toolResults = outcomes.map((outcome, index) => + toToolResult(toolCalls[index], outcome), +); +``` + +`Promise.allSettled()` 允许 Task 乱序完成,但返回数组仍与输入 Promise 保持相同索引。 + +实时 `tool_start`、`tool_result` 事件可以按实际发生顺序发布;事件必须携带 `toolCallId` 和 `sequence`,不能依赖事件抵达顺序完成配对。 + +## 15. `exclusive_step` Admission + +资源调度前保留现有 step admission: + +```text +ToolCalls + ↓ +exclusive_step admission + ├─ admitted → 准备并执行 + └─ rejected → synthetic Tool Result +``` + +建议第一阶段保持既有行为,避免把资源调度改造与控制面语义变更混在一起: + +- `exclusive_step` 作为首个被接纳调用时执行,后续冲突调用被拒绝; +- 普通调用已经被接纳后遇到 `exclusive_step`,该 exclusive 调用被拒绝; +- admission rejection 不执行真实副作用,使用 `none()` 或直接 resolved slot; +- `AskUserQuestion`、权限请求和 `SubmitPlan` 等控制工具继续通过此机制形成明确的 step 边界。 + +## 16. 失败语义 + +### 16.1 模型可见失败 + +以下失败应归一化为正常 Tool Result,Task Promise 可以 fulfilled: + +- 参数错误; +- 工具不存在; +- 权限被拒绝; +- admission 被拒绝; +- Tool 业务错误; +- 可确认没有产生不确定副作用的执行失败。 + +### 16.2 Turn 级失败 + +以下失败不得伪装成普通 Tool Result: + +- T1/T2 durable commit 失败; +- 无法判断外部副作用是否已经发生; +- Runtime ledger 或事件一致性被破坏; +- Scheduler 内部不变量被破坏。 + +Batch Runner 使用 `Promise.allSettled()` 等待全部 Task 进入终态后,再把基础设施级 rejection 提升为 Turn 级错误。 + +一个普通 Tool 失败不会自动取消同批其他 Task。 + +## 17. 取消和超时 + +所有 Task 共享 Turn 的 abort signal,但 queued 和 active Task 的处理不同: + +### 17.1 Queued Task + +- abort 后不得调用 `run()`; +- 必须从 queued 中移除; +- result Promise 必须 settle,不能永久悬挂; +- 根据 Turn 协议转换为 cancellation result 或 rejection。 + +### 17.2 Active Task + +- 把 abort signal 传递给 ToolRuntime 和 Tool 实现; +- Tool 实现应尽快终止可取消操作; +- 无论成功、失败还是取消,最终都必须释放 active 状态并触发 `drainQueue()`。 + +## 18. 容量限制 + +资源冲突和容量限制是两个不同问题: + +- 资源冲突回答“两个 Task 能否安全地同时运行”; +- 容量限制回答“系统当前最多允许多少个 Task 同时运行”。 + +不要通过伪造资源冲突表达 API QPS、进程数或连接数限制。建议为 Scheduler 或外围 Coordinator 增加独立 capacity policy: + +```ts +interface CapacityRequest { + readonly key: string; + readonly units?: number; +} +``` + +典型 key: + +```text +provider:web-search +mcp-server:{serverId} +subagent-spawn +process:workspace:{workspaceId} +``` + +第一阶段可以只实现资源冲突,容量限制作为后续扩展。 + +## 19. Scheduler 生命周期和协调范围 + +第一阶段采用 batch-local Scheduler: + +```text +一个 assistant step + → 一个 Tool Call batch + → 一个 ToolScheduler + → batch 完成后销毁 +``` + +它能够解决同一 batch 内的竞态,但不能阻止以下跨边界冲突: + +- 两个并行 Turn 修改同一 workspace 文件; +- 父 Agent 与子 Agent 修改同一资源; +- 不同 Runtime 进程操作同一终端或浏览器会话。 + +如果未来需要跨 batch 保证,应抽取共享 `ResourceCoordinator`: + +```text +Batch ToolScheduler + ↓ +Workspace/Session ResourceCoordinator + ↓ +Runtime Host +``` + +共享 Coordinator 可以复用相同的 `ResourceAccess` 和冲突模型,但它需要额外处理租约、进程退出、恢复和跨进程一致性,不属于第一阶段范围。 + +## 20. 可观察性 + +建议为每个 Task 记录: + +- `toolCallId`; +- `sequence`; +- Tool 名称; +- accesses 摘要; +- `queuedAt`; +- `startedAt`; +- `finishedAt`; +- queue wait duration; +- execution duration; +- blocking task/resource; +- settlement 类型。 + +推荐事件: + +```text +tool_task_prepared +tool_task_queued +tool_task_started +tool_task_finished +tool_task_cancelled +``` + +`queued` 是调度状态,不应被包装成最终 Tool Result。模型最终只应看到执行结果、业务失败、admission rejection 或取消结果。 + +## 21. 必须保持的不变量 + +1. `activeTasks` 中任意两个 Task 都不冲突。 +2. 同一个 Task 最多调用一次 `run()`。 +3. 同一个 Task 不会同时存在于 active 和 queued。 +4. finished Task 不再存在于 active 或 queued。 +5. 后到的 Task 不会越过与它冲突的前序 queued Task。 +6. 非冲突 Task 不会仅因为队列非空而等待。 +7. Task 成功、失败、取消或同步抛错后都会释放 active 状态。 +8. 一次资源释放后,所有当前满足条件的 Task 都会被启动。 +9. Task result Promise 最终只 settle 一次。 +10. Scheduler 内部不会产生 detached unhandled rejection。 +11. 每个原始 Tool Call 恰好对应一个最终 result slot。 +12. ToolResults 的最终顺序与原始 Tool Calls 顺序一致。 + +## 22. 验收测试 + +### 22.1 冲突关系 + +- 同路径 read/read 并发。 +- 同路径 read/write 串行。 +- 同路径 write/write 串行。 +- 不同路径 write/write 并发。 +- 递归目录访问与子文件正确冲突。 +- 相似前缀目录不会误判为父子目录。 +- 多 accesses Task 任意一项冲突时整体等待。 +- `all` 与任意非空 accesses 冲突。 +- `none` 不阻塞任何 Task。 + +### 22.2 队列公平性 + +- 后到的独立 Task 可以越过前面的 queued Task。 +- 后到的冲突 Task 不能越过前面的 queued Task。 +- writer 排队后,新 reader 不能继续越过 writer。 +- 一次 drain 可以启动多个互不冲突的 Task。 + +### 22.3 生命周期 + +- active Task resolve 后释放资源。 +- active Task reject 后释放资源。 +- `run()` 同步抛错时正确 reject 并推进队列。 +- queued Task 取消后不会启动。 +- active Task 取消后最终释放资源。 +- Task 不会重复 start、finish 或 settle。 + +### 22.4 结果顺序 + +- Task 可以按照 B、C、A 的顺序完成。 +- 最终 ToolResults 仍按照 A、B、C 返回。 +- synthetic result 和真实执行结果混合时,结果槽位仍与原始 Tool Call 一一对应。 +- 单个业务失败不会阻断其他 Task。 +- 基础设施级 rejection 在整批 settle 后升级为 Turn 级错误。 + +### 22.5 Admission + +- `exclusive_step` 不会被普通资源队列语义替代。 +- admission rejection 不执行 Tool 副作用。 +- 被拒绝调用仍产生与 Tool Call 配对的 synthetic result。 + +## 23. 演进计划 + +### 第一阶段:核心骨架 + +1. 引入 `ToolCallTask`、`ToolAccesses` 和 `ToolScheduler`。 +2. 保持现有 `exclusive_step` admission。 +3. Batch Runner 为 Tool Call 预分配固定 sequence。 +4. 使用 Scheduler 替换直接 `map(settleToolCall)` 启动方式。 +5. 使用有序 result slot 和 `Promise.allSettled()` 聚合。 +6. 优先覆盖 `Read`、`Write`、`Edit`、`Glob`、`Grep` 和 `apply_patch`。 + +### 第二阶段:逻辑资源 + +1. Todo、Goal、Plan 使用 Session/execution key。 +2. Terminal 使用 `(sessionId, ref)` key。 +3. Browser 使用 session/tab key。 +4. Computer Use 使用 device/window key。 +5. 为队列等待和阻塞原因增加观测指标。 + +### 第三阶段:外部系统与容量 + +1. Web provider 并发上限。 +2. MCP server/session/resource 策略。 +3. Agent spawn 并发上限。 +4. 评估跨 batch、跨 Agent 的共享 `ResourceCoordinator`。 + +## 24. 最终职责边界 + +```text +ToolTaskFactory + 决定“本次调用会访问什么资源” + +ToolScheduler + 决定“本次调用什么时候可以启动” + +ToolRuntime + 决定“本次调用如何可靠执行和持久化” + +ToolResultAssembler + 决定“结果以什么顺序交给模型” +``` + +## 25. 一句话定义 + +> 每个 Tool Call 被转换成一个携带完整 accesses 和固定 sequence 的 Task;新 Task 与任意 active Task 或前序 queued Task 冲突时排队,否则立即执行;整批 Task settle 后,Batch Runner 按原始 Tool Call 顺序组装 ToolResults。 + +## 26. 参考材料 + +- [`apache/maka#4487`](https://github.com/apache/maka/issues/4487) From 3f293d82bcbe7cc6ae138ebdfc11ed2effd5bf81 Mon Sep 17 00:00:00 2001 From: jarad-z <2280669499@qq.com> Date: Fri, 4 Sep 2026 16:13:43 +0800 Subject: [PATCH 2/6] feat(runtime): execute tool calls through resource authorities Generated-by: Codex --- .../execution-model-composition.test.ts | 31 ++ .../src/server/execution-composition.ts | 9 + .../src/server/execution-model-composition.ts | 3 + packages/runtime/package.json | 1 + .../src/__tests__/ai-sdk-backend.test.ts | 71 ++- .../src/__tests__/builtin-tool-access.test.ts | 265 +++++++--- .../builtin-tools-file-worker.test.ts | 47 +- .../domain-tool-authority-fallbacks.test.ts | 112 ++++ .../execution-boundary-test-helpers.ts | 10 +- ...lesystem-tool-call-batch-scenarios.test.ts | 483 ++++++++++++++++++ .../src/__tests__/one-shot-operation.test.ts | 87 ++++ ...ool-authority-kimi-semantics-batch.test.ts | 402 +++++++++++++++ .../src/__tests__/tool-call-batch.test.ts | 234 ++++----- .../tool-preparation-service.test.ts | 193 +++++++ .../src/__tests__/tool-scheduler.test.ts | 268 +++++++--- packages/runtime/src/builtin-tools.ts | 339 ++++++++++-- packages/runtime/src/filesystem-executor.ts | 240 ++++++++- packages/runtime/src/preparation/claims.ts | 88 ++++ .../preparation/default-tool-authorities.ts | 79 +++ .../preparation/domain-authority-contracts.ts | 186 +++++++ .../src/preparation/one-shot-operation.ts | 86 ++++ .../preparation/placeholder-authorities.ts | 64 +++ .../src/preparation/target-identity.ts | 51 ++ .../preparation/tool-authority-registry.ts | 74 +++ .../preparation/tool-preparation-service.ts | 237 +++++++++ packages/runtime/src/preparation/types.ts | 144 ++++++ packages/runtime/src/tool-call-batch.ts | 112 ++-- packages/runtime/src/tool-preparation.ts | 71 +++ packages/runtime/src/tool-runtime.ts | 27 +- packages/runtime/src/tool-scheduler.ts | 63 ++- 30 files changed, 3695 insertions(+), 382 deletions(-) create mode 100644 packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts create mode 100644 packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts create mode 100644 packages/runtime/src/__tests__/one-shot-operation.test.ts create mode 100644 packages/runtime/src/__tests__/tool-authority-kimi-semantics-batch.test.ts create mode 100644 packages/runtime/src/__tests__/tool-preparation-service.test.ts create mode 100644 packages/runtime/src/preparation/claims.ts create mode 100644 packages/runtime/src/preparation/default-tool-authorities.ts create mode 100644 packages/runtime/src/preparation/domain-authority-contracts.ts create mode 100644 packages/runtime/src/preparation/one-shot-operation.ts create mode 100644 packages/runtime/src/preparation/placeholder-authorities.ts create mode 100644 packages/runtime/src/preparation/target-identity.ts create mode 100644 packages/runtime/src/preparation/tool-authority-registry.ts create mode 100644 packages/runtime/src/preparation/tool-preparation-service.ts create mode 100644 packages/runtime/src/preparation/types.ts create mode 100644 packages/runtime/src/tool-preparation.ts 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/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 7c0f8f90e0..cb7b2a8f6b 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -77,6 +77,8 @@ import { validateShellPreference, } from '@maka/runtime/shell-detect'; import { type MakaTool } from '@maka/runtime/tool-runtime'; +import { buildBuiltinToolComposition } from '@maka/runtime/builtin-tools'; +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'; @@ -450,6 +452,12 @@ export async function createExecutionRuntimeHostComposition( ...(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, + ); const webSearchService = createHostWebSearchService({ policy: runtimePolicyStores.operations, }); @@ -733,6 +741,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..5d34f6e5ff 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -113,6 +113,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 bc0718606a..73f399167a 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -97,6 +97,8 @@ 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 () => { @@ -5190,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, @@ -6924,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'); @@ -11058,16 +11086,44 @@ describe('AiSdkBackend tool execution', () => { path: z.string(), operation: z.enum(['read', 'write']), }), - resolveAccesses: ({ path, operation }, context) => - operation === 'read' - ? ToolAccesses.readFile(path, { cwd: context.cwd }) - : ToolAccesses.writeFile(path, { cwd: context.cwd }), impl: async ({ label }) => { - started.push(label); - await gates.get(label)!.promise; - return { 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(), @@ -11077,6 +11133,7 @@ describe('AiSdkBackend tool execution', () => { modelId: 'mock-model-id', modelFactory: () => model, tools: [scheduledTool], + preparationService, maxSteps: 3, loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, newId: idGenerator(), diff --git a/packages/runtime/src/__tests__/builtin-tool-access.test.ts b/packages/runtime/src/__tests__/builtin-tool-access.test.ts index 5f6cda1e06..4547074c39 100644 --- a/packages/runtime/src/__tests__/builtin-tool-access.test.ts +++ b/packages/runtime/src/__tests__/builtin-tool-access.test.ts @@ -18,102 +18,245 @@ */ import assert from 'node:assert/strict'; -import { resolve } from 'node:path'; -import { describe, test } from 'node:test'; -import { buildBuiltinTools } from '../builtin-tools.js'; -import { - normalizeToolFilePath, - ToolAccesses, - type ToolAccesses as AccessSet, -} from '../tool-access.js'; -import type { MakaTool, MakaToolAccessContext } from '../tool-runtime.js'; +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 { ToolPreparationService } from '../preparation/tool-preparation-service.js'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; -describe('builtin tool access declarations', () => { - const cwd = resolve('workspace'); - const tools = new Map(buildBuiltinTools().map((tool) => [tool.name, tool])); - const expectedPath = (path: string) => normalizeToolFilePath(path, { cwd }); +describe('builtin tool resource claims', () => { + let cwd: string; + const tools = new Map(); + let preparationService: ToolPreparationService; + // claim.key must equal the executor's lock key. On Windows realpath returns + // backslash paths, and normalising here would break the claim==lock key + // invariant, so the key is compared verbatim. + const expectedKey = (path: string) => resolve(cwd, path); - test('maps file reads and writes to their concrete targets', async () => { - assert.deepEqual(await accesses(tools, 'Read', { path: 'a.ts' }, cwd), [ - { kind: 'file', operation: 'read', path: expectedPath('a.ts') }, - ]); - assert.deepEqual(await accesses(tools, 'Write', { path: 'a.ts', content: 'x' }, cwd), [ - { kind: 'file', operation: 'write', path: expectedPath('a.ts') }, + 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' }, ]); + assert.deepEqual( + await claims(preparationService, tools, 'Write', { path: 'a.ts', content: 'x' }, cwd), + [ + { + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey('a.ts'), + mode: 'write', + }, + ], + ); for (const [name, input] of [ ['Edit', { path: 'a.ts', old_string: 'a', new_string: 'b' }], ['FormatJson', { path: 'a.json' }], ] as const) { - assert.deepEqual(await accesses(tools, name, input, cwd), [ + assert.deepEqual(await claims(preparationService, tools, name, input, cwd), [ { - kind: 'file', - operation: 'readwrite', - path: expectedPath(input.path), + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey(input.path), + mode: 'write', }, ]); } }); - test('keeps runtime-resource reads fail-closed', async () => { + 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 accesses(tools, 'Read', { ref: 'runtime://resource' }, cwd), - ToolAccesses.all(), + await claims(preparationService, tools, 'Read', { ref: 'runtime://resource' }, cwd), + [], ); }); - test('maps Glob and Grep to recursive search roots', async () => { - assert.deepEqual(await accesses(tools, 'Glob', { pattern: '**/*.ts', cwd: 'src' }, cwd), [ - { - kind: 'file', - operation: 'search', - path: expectedPath('src'), - recursive: true, - }, - ]); - assert.deepEqual(await accesses(tools, 'Grep', { pattern: 'TODO' }, 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: 'file', - operation: 'search', - path: expectedPath('.'), - recursive: true, + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey('.'), + mode: 'read', + scope: 'tree', }, ]); }); - test('declares every valid apply_patch target as one atomic access set', async () => { - const patch = [ - '*** Begin Patch', - '*** Add File: added.txt', - '+added', - '*** Update File: changed.txt', - '@@', - '-before', - '+after', - '*** End Patch', - ].join('\n'); - assert.deepEqual(await accesses(tools, 'apply_patch', patch, cwd), [ - { kind: 'file', operation: 'write', path: expectedPath('added.txt') }, - { kind: 'file', operation: 'write', path: expectedPath('changed.txt') }, - ]); + 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', + }, + ], + ); + // A string (multi-operation) patch falls back to the plain impl. Until the + // parser is shared safely with prepare, it must conservatively claim all. + assert.deepEqual( + await claims( + preparationService, + tools, + 'apply_patch', + '*** Begin Patch\n+x\n*** End Patch', + cwd, + ), + [{ kind: 'all' }], + ); + }); + + 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 accesses( +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 { +): Promise { const tool = tools.get(name); - if (!tool?.resolveAccesses) throw new Error(`${name} has no access declaration`); - const context: MakaToolAccessContext = { + if (!tool) throw new Error(`${name} is not registered`); + const context: MakaToolContext = { sessionId: 'session-1', - runId: 'run-1', turnId: 'turn-1', cwd, permissionMode: 'ask', toolCallId: `${name}-call`, abortSignal: new AbortController().signal, + emitOutput: () => {}, }; - return (await tool.resolveAccesses(input, context)) ?? ToolAccesses.all(); + 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..b67bd1c46a 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 () => { 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..0f89b021fa --- /dev/null +++ b/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts @@ -0,0 +1,112 @@ +/* + * 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('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__/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..4b6643e079 --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts @@ -0,0 +1,483 @@ +/* + * 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 type { ResourceClaim } from '../preparation/types.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', () => { + 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[], +): { + readonly claims: Map; + readonly outcomes: Promise[]>; +} { + const composition = buildBuiltinToolComposition({ + filesystemWorker: { execute: (input) => observer.execute(input) }, + }); + const preparation = new ToolPreparationService(composition.authorityRegistry); + 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 }; + }, + }; + }), + ); + + 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 base = `${input.operation.kind}:${input.operation.path.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 key = relative(cwd, claim.key).replaceAll('\\', '/') || '.'; + 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__/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__/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..2b41904ad9 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-authority-kimi-semantics-batch.test.ts @@ -0,0 +1,402 @@ +/* + * 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 index a25df15982..758226271c 100644 --- a/packages/runtime/src/__tests__/tool-call-batch.test.ts +++ b/packages/runtime/src/__tests__/tool-call-batch.test.ts @@ -19,43 +19,60 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { ToolAccesses } from '../tool-access.js'; -import { settleToolCallBatch } from '../tool-call-batch.js'; +import { settleToolCallBatch, type ToolCallBatchEntry } from '../tool-call-batch.js'; +import type { PreparedOperation, ResourceClaim } from '../preparation/types.js'; -const POSIX = { cwd: '/repo', platform: 'linux' as const }; +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 access plan before submitting tasks in model order', async () => { + 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( - [ - { - id: 'first', - resolveAccesses: async () => { - await preparation.promise; - return ToolAccesses.writeFile('/repo/a', POSIX); - }, - run: () => { - starts.push('first'); - return first.promise; - }, + const batch = settleToolCallBatch([ + entry( + 'first', + async () => { + await preparation.promise; + return prepared(write('/repo/a')); }, - { - id: 'second', - resolveAccesses: () => ToolAccesses.writeFile('/repo/b', POSIX), - run: () => { - starts.push('second'); - bothStarted.resolve(); - return second.promise; - }, + () => { + starts.push('first'); + return first.promise; }, - ], - POSIX, - ); + ), + entry( + 'second', + async () => { + await preparation.promise; + return prepared(write('/repo/b')); + }, + () => { + starts.push('second'); + bothStarted.resolve(); + return second.promise; + }, + ), + ]); await flushMicrotasks(); assert.deepEqual(starts, []); @@ -73,12 +90,13 @@ describe('settleToolCallBatch', () => { 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) => ({ - id: String(index), - resolveAccesses: () => ToolAccesses.none(), - run: () => gate.promise, - })), - POSIX, + gates.map((gate, index) => + entry( + String(index), + async () => prepared(none()), + () => gate.promise, + ), + ), ); gates[1]!.resolve('B'); @@ -96,74 +114,83 @@ describe('settleToolCallBatch', () => { ); }); - test('fails closed to all when an access declaration is absent or throws', async () => { - for (const resolveAccesses of [ - undefined, - () => { - throw new Error('bad declaration'); - }, - ]) { - const first = deferred(); - const unknownStarted = deferred(); - const starts: string[] = []; - const batch = settleToolCallBatch( - [ - { - id: 'unknown', - ...(resolveAccesses ? { resolveAccesses } : {}), - run: () => { - starts.push('unknown'); - unknownStarted.resolve(); - return first.promise; - }, - }, - { - id: 'writer', - resolveAccesses: () => ToolAccesses.writeFile('/repo/a', POSIX), - run: () => { - starts.push('writer'); - return undefined; - }, - }, - ], - POSIX, - ); + 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 unknownStarted.promise; - assert.deepEqual(starts, ['unknown']); - first.resolve(); - await batch; - assert.deepEqual(starts, ['unknown', 'writer']); - } + 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 queued task cancelled during preparation', async () => { + 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( - [ - { - id: 'preparing', - resolveAccesses: async () => { - await preparation.promise; - return ToolAccesses.none(); - }, - run: () => 'ok', + const batch = settleToolCallBatch([ + entry( + 'preparing', + async () => { + await preparation.promise; + return prepared(none()); }, - { - id: 'cancelled', - signal: controller.signal, - resolveAccesses: () => ToolAccesses.none(), - run: () => { - starts += 1; - return 'should not run'; - }, + () => 'ok', + ), + entry( + 'cancelled', + async () => { + await preparation.promise; + return prepared(none()); }, - ], - POSIX, - ); + () => { + 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; @@ -171,29 +198,6 @@ describe('settleToolCallBatch', () => { assert.equal(outcomes[0]?.status, 'fulfilled'); assert.equal(outcomes[1]?.status, 'rejected'); }); - - test('abort releases a batch whose access planner never settles', async () => { - const controller = new AbortController(); - let starts = 0; - const batch = settleToolCallBatch( - [ - { - id: 'hung-planner', - signal: controller.signal, - resolveAccesses: () => new Promise(() => {}), - run: () => { - starts += 1; - }, - }, - ], - POSIX, - ); - - controller.abort(new Error('stop planning')); - const outcomes = await batch; - assert.equal(starts, 0); - assert.equal(outcomes[0]?.status, 'rejected'); - }); }); function deferred() { 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..4ab272353b --- /dev/null +++ b/packages/runtime/src/__tests__/tool-preparation-service.test.ts @@ -0,0 +1,193 @@ +/* + * 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'; + +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('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/); + }); +}); diff --git a/packages/runtime/src/__tests__/tool-scheduler.test.ts b/packages/runtime/src/__tests__/tool-scheduler.test.ts index 9cbc08e599..7abc54eb2b 100644 --- a/packages/runtime/src/__tests__/tool-scheduler.test.ts +++ b/packages/runtime/src/__tests__/tool-scheduler.test.ts @@ -19,13 +19,42 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { ToolAccesses } from '../tool-access.js'; +import type { PreparedOperation, ResourceClaim } from '../preparation/types.js'; import { ToolScheduler } from '../tool-scheduler.js'; -const POSIX = { cwd: '/repo', platform: 'linux' as const }; +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 tasks immediately', async () => { + test('starts non-conflicting (overlapping-reader) tasks immediately', async () => { const scheduler = new ToolScheduler(); const first = deferred(); const second = deferred(); @@ -34,20 +63,20 @@ describe('ToolScheduler', () => { const firstResult = scheduler.add({ id: 'read-a-1', sequence: 0, - accesses: ToolAccesses.readFile('/repo/a', POSIX), - run: () => { + operation: operation(read('/repo/a'), () => { started.push('first'); return first.promise; - }, + }), + run: runThrough(), }); const secondResult = scheduler.add({ id: 'read-a-2', sequence: 1, - accesses: ToolAccesses.readFile('/repo/a', POSIX), - run: () => { + operation: operation(read('/repo/a'), () => { started.push('second'); return second.promise; - }, + }), + run: runThrough(), }); assert.deepEqual(started, ['first', 'second']); @@ -68,24 +97,24 @@ describe('ToolScheduler', () => { const task = ( id: string, sequence: number, - accesses: ReturnType, + claims: readonly ResourceClaim[], gate: ReturnType>, ) => scheduler.add({ id, sequence, - accesses, - run: () => { + operation: operation(claims, () => { started.push(id); return gate.promise; - }, + }), + run: runThrough(), }); const results = [ - task('reader-1', 0, ToolAccesses.readFile('/repo/a', POSIX), reader), - task('writer', 1, ToolAccesses.writeFile('/repo/a', POSIX), writer), - task('reader-2', 2, ToolAccesses.readFile('/repo/a', POSIX), laterReader), - task('independent', 3, ToolAccesses.writeFile('/repo/b', POSIX), independent), + 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']); @@ -110,29 +139,29 @@ describe('ToolScheduler', () => { scheduler.add({ id: 'all', sequence: 0, - accesses: ToolAccesses.all(), - run: () => { + operation: operation(all(), () => { started.push('all'); return blocker.promise; - }, + }), + run: runThrough(), }), scheduler.add({ id: 'a', sequence: 1, - accesses: ToolAccesses.writeFile('/repo/a', POSIX), - run: () => { + operation: operation(write('/repo/a'), () => { started.push('a'); return a.promise; - }, + }), + run: runThrough(), }), scheduler.add({ id: 'b', sequence: 2, - accesses: ToolAccesses.writeFile('/repo/b', POSIX), - run: () => { + operation: operation(write('/repo/b'), () => { started.push('b'); return b.promise; - }, + }), + run: runThrough(), }), ]; @@ -145,35 +174,50 @@ describe('ToolScheduler', () => { await Promise.all(results); }); - test('releases resources after asynchronous rejection and synchronous throw', async () => { - for (const firstRun of [ - () => Promise.reject(new Error('async failure')), - () => { - throw new Error('sync failure'); - }, - ]) { - const scheduler = new ToolScheduler(); - const started: string[] = []; - const first = scheduler.add({ - id: 'first', - sequence: 0, - accesses: ToolAccesses.writeFile('/repo/a', POSIX), - run: firstRun, - }); - const second = scheduler.add({ - id: 'second', - sequence: 1, - accesses: ToolAccesses.readFile('/repo/a', POSIX), - run: () => { - started.push('second'); - return 'ok'; - }, - }); + 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, /failure/); - assert.equal(await second, 'ok'); - assert.deepEqual(started, ['second']); - } + 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 () => { @@ -184,17 +228,17 @@ describe('ToolScheduler', () => { const activeResult = scheduler.add({ id: 'active', sequence: 0, - accesses: ToolAccesses.writeFile('/repo/a', POSIX), - run: () => active.promise, + operation: operation(write('/repo/a'), () => active.promise), + run: runThrough(), }); const queuedResult = scheduler.add({ id: 'queued', sequence: 1, - accesses: ToolAccesses.writeFile('/repo/a', POSIX), - signal: controller.signal, - run: () => { + operation: operation(write('/repo/a'), () => { queuedStarts += 1; - }, + }), + signal: controller.signal, + run: runThrough(), }); const queuedOutcome = Promise.allSettled([queuedResult]); @@ -206,53 +250,125 @@ describe('ToolScheduler', () => { await activeResult; }); - test('releases active work after the tool observes cancellation', async () => { + 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, - accesses: ToolAccesses.writeFile('/repo/a', POSIX), + operation: operation( + write('/repo/a'), + () => + new Promise((resolve) => { + controller.signal.addEventListener('abort', () => resolve('cancelled-but-fulfilled'), { + once: true, + }); + }), + ), signal: controller.signal, - run: () => - new Promise((_resolve, reject) => { - controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { - once: true, - }); - }), + run: runThrough(), }); const next = scheduler.add({ id: 'next', sequence: 1, - accesses: ToolAccesses.readFile('/repo/a', POSIX), - run: () => { + operation: operation(read('/repo/a'), () => { started.push('next'); return 'done'; - }, + }), + run: runThrough(), }); controller.abort(new Error('cancel active')); - await assert.rejects(active, /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, - accesses: ToolAccesses.none(), - run: () => undefined, + operation: operation(none(), () => undefined), + run: runThrough(), }); assert.throws( () => scheduler.add({ id: 'duplicate', sequence: 1, - accesses: ToolAccesses.none(), - run: () => undefined, + operation: operation(none(), () => undefined), + run: runThrough(), }), /strictly increasing sequence order/, ); diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index 8862c77d8a..514c6a2232 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -67,8 +67,25 @@ import { } from './workspace-executor.js'; import { createBoundaryFilesystemExecutor, + createFilesystemResourceAuthority, type FilesystemExecuteInput, + type FilesystemResult, } from './filesystem-executor.js'; +import { + allResourceAuthority, + 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 @@ -93,7 +110,6 @@ import { sandboxBoundaryExpansionSchema, selectedBashBoundaryExpansion, } from './sandbox-boundary-declaration.js'; -import { ToolAccesses, type ToolResourceAccess } from './tool-access.js'; // Generous wall-clock cap for the ripgrep-backed Grep tool. A search should be // near-instant; this only bounds a pathological hang now that the stream @@ -192,13 +208,32 @@ 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({ workspace: executor, ...(options.filesystemWorker ? { worker: options.filesystemWorker } : {}), ...(options.permissionProfile ? { permissionProfile: options.permissionProfile } : {}), }); + const filesystemAuthority = includeAuthorities + ? createFilesystemResourceAuthority({ + workspace: executor, + ...(options.filesystemWorker ? { worker: options.filesystemWorker } : {}), + ...(options.permissionProfile ? { permissionProfile: options.permissionProfile } : {}), + }) + : 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' : ''}.`; @@ -326,27 +361,70 @@ 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, - resolveAccesses: (input, ctx) => { - const operations = - typeof input === 'string' - ? parseCodexV4aPatch(input) - : input && typeof input === 'object' && 'operation' in input - ? [input.operation] - : undefined; - if (!operations) return ToolAccesses.all(); - return operations.map( - (operation): ToolResourceAccess => - ToolAccesses.writeFile(operation.path, { cwd: ctx.cwd })[0]!, - ); - }, + resourceAuthority: filesystemAuthority + ? { + prepare: async (input, ctx) => { + const operation = + typeof input === 'string' + ? undefined + : input && typeof input === 'object' && 'operation' in input + ? (input as { operation: { type: string; path: string; diff?: string } }) + .operation + : undefined; + // The string protocol may contain multiple filesystem operations. + // Until its parser is safely reusable during prepare, conservatively + // claim all modelled resources and execute the live tool impl once. + if (!operation) return allResourceAuthority().prepare(input, ctx); + 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) }); @@ -360,8 +438,8 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ctx.abortSignal, ); }, - } satisfies MakaTool; - const tools: MakaTool[] = [ + } satisfies AuthorityBoundMakaTool; + const tools: AuthorityBoundMakaTool[] = [ ...bashTools, ...backgroundTools, { @@ -399,10 +477,45 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT }, } : {}), - resolveAccesses: (input, ctx) => - input && typeof input === 'object' && 'path' in input && typeof input.path === 'string' - ? ToolAccesses.readFile(input.path, { cwd: ctx.cwd }) - : ToolAccesses.all(), + 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) { @@ -477,7 +590,22 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT content: z.string(), }), executionFacts, - resolveAccesses: ({ path }, ctx) => ToolAccesses.writeFile(path, { cwd: ctx.cwd }), + 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 }, @@ -506,7 +634,39 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT new_string: z.string(), }), executionFacts, - resolveAccesses: ({ path }, ctx) => ToolAccesses.readWriteFile(path, { cwd: ctx.cwd }), + 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: { @@ -558,7 +718,24 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT .describe('Sort object keys lexicographically; default false.'), }), executionFacts, - resolveAccesses: ({ path }, ctx) => ToolAccesses.readWriteFile(path, { cwd: ctx.cwd }), + 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: { @@ -599,7 +776,24 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ), }), executionFacts, - resolveAccesses: ({ cwd }, ctx) => ToolAccesses.searchTree(cwd ?? '.', { cwd: ctx.cwd }), + 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 }, @@ -624,7 +818,32 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT glob: z.string().optional(), }), executionFacts, - resolveAccesses: ({ path }, ctx) => ToolAccesses.searchTree(path ?? '.', { cwd: ctx.cwd }), + 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 @@ -655,9 +874,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(), + ), + }; +} + +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, @@ -667,6 +910,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: ReturnType | 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/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index 53fd789449..9de9e2d3c2 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -44,6 +44,18 @@ import { import { StableWriteFailure } from './file-stable-write.js'; import { applyUpdateToContent } from './apply-patch-file.js'; import { withFileWriteLock } from './file-write-lock.js'; +import { + identityChanged, + 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, @@ -185,9 +197,24 @@ 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 { + execute(input: FilesystemExecuteInput): Promise; + applyPatch(input: FilesystemApplyPatchInput): Promise; + run( + call: FilesystemBackendExecuteInput, + expectedIdentity?: FilesystemTargetIdentity, + ): 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 = ( @@ -283,7 +310,41 @@ 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, + ); + // Capture the target's stable identity at T0, BEFORE waiting for the lock. + // 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. This is the resolver the Scheduler's claim.key is derived from, + // so claim key === lock key === canonical path by construction. + const identity = await captureIdentityAtLockAcquisition( + canonicalPath, + inputArg.semantics === 'target', + ); + return { + canonicalPath: key ?? canonicalPath, + identity: toTargetIdentity(identity, inputArg.semantics), + }; + } return { + resolveTarget, + run, async execute(call) { if (operationAccess(call.operation.kind) !== 'write') return await run(call); // Canonicalisation without any containment check, so a target the policy @@ -338,6 +399,179 @@ export function createBoundaryFilesystemExecutor( }; } +export function createBoundaryFilesystemExecutor( + input: BoundaryFilesystemExecutorInput, +): FilesystemExecutor { + const backend = buildFilesystemBackend(input); + return { + async execute(call) { + return await backend.execute(call); + }, + async applyPatch(call) { + return await backend.applyPatch(call); + }, + }; +} + +/** + * 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 at prepare-time (T0), expressed in the + * authority's vocabulary. A create target with no on-disk inode is `missing`. + */ +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 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 }; +} + +function toAuthorityClaims( + operation: { path: string }, + canonicalPath: string, + target: FilesystemAuthorityInput, +): KeyedResourceClaim[] { + const authority = 'filesystem:workspace'; + if (isSearchOperation(target)) { + // Tree read: conflicts with any in-tree write (Grep(src) vs Write(src/a.ts)). + return [{ kind: 'keyed', authority, key: canonicalPath, mode: 'read', scope: 'tree' }]; + } + if (!isWriteOperation(target)) { + return [{ kind: 'keyed', authority, key: canonicalPath, mode: 'read' }]; + } + return [{ kind: 'keyed', authority, key: canonicalPath, mode: 'write' }]; +} + +function toBackendCall( + target: FilesystemAuthorityInput, + context: AuthorityContext, + 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 } : {}), + }; +} + +/** + * The filesystem domain authority. `prepare` captures the canonical identity + * (claim key == lock key == canonical path), and `execute` re-resolves that + * identity, compares it, takes the write lock, runs the effect, and settles the + * failure (path_changed / outcome_unknown). Reads and searches run unlocked. + */ +export function createFilesystemResourceAuthority( + input: BoundaryFilesystemExecutorInput, +): ResourceAuthority { + const backend = buildFilesystemBackend(input); + return { + async prepare(target, context): Promise> { + const semantics = filesystemSemantics(target); + const { path } = toBackendOperation(target); + const resourceArgs = { + cwd: context.cwd, + path, + semantics, + executionBoundary: context.executionBoundary, + permissionMode: context.permissionMode, + abortSignal: context.abortSignal, + }; + const resolved = await backend.resolveTarget(resourceArgs); + const claims = toAuthorityClaims({ path }, resolved.canonicalPath, target); + const writes = isWriteOperation(target); + + const execute = async (signal?: AbortSignal): Promise => { + if (!writes) { + return await backend.run(toBackendCall(target, context, signal)); + } + const now = await backend.resolveTarget({ + ...resourceArgs, + abortSignal: signal ?? context.abortSignal, + }); + if (identityChanged(resolved.identity, now.identity)) { + // business failure -> fulfilled error result, not a fatal rejection. + throw new Error('The approved filesystem target changed before execution.'); + } + const expectedIdentity = toExpectedIdentity(now.identity); + try { + return await withFileWriteLock(now.canonicalPath, () => + backend.run(toBackendCall(target, context, signal), expectedIdentity), + ); + } catch (error) { + throw settleMutationFailure(error); + } + }; + return oneShotOperation({ claims, execute }); + }, + }; +} + /** * Settle a failed mutation into its caller-facing error. A pinned-primitive * failure maps by code: `outcome_unknown` (the write may have partially diff --git a/packages/runtime/src/preparation/claims.ts b/packages/runtime/src/preparation/claims.ts new file mode 100644 index 0000000000..efd76e0212 --- /dev/null +++ b/packages/runtime/src/preparation/claims.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. + */ + +// 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 { + // Placeholder `all()` is the fail-closed global serialization marker. + 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 { + if (a.key === b.key) return true; + const aTree = a.scope === 'tree'; + const bTree = b.scope === 'tree'; + if (aTree && isWithin(a.key, b.key)) return true; + if (bTree && isWithin(b.key, a.key)) return true; + return false; +} + +function isWithin(parent: string, candidate: string): boolean { + 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..eebbdcf1db --- /dev/null +++ b/packages/runtime/src/preparation/default-tool-authorities.ts @@ -0,0 +1,79 @@ +/* + * 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 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', + '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(): 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()] 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..06241b70fd --- /dev/null +++ b/packages/runtime/src/preparation/one-shot-operation.ts @@ -0,0 +1,86 @@ +/* + * 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..a4812d550a --- /dev/null +++ b/packages/runtime/src/preparation/placeholder-authorities.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. + */ + +// 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'}] -> conflicts with everything -> global +// serialization against non-empty claims, fail-closed. It is the conservative +// default for real effects whose precise authority is not registered yet. + +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 = (): ResourceAuthority => ({ + async prepare(_input, context: AuthorityContext): Promise> { + const { effect } = context; + return oneShotOperation({ + claims: [{ kind: 'all' }], + execute: (signal, fallbackEffect) => + fallbackEffect ? fallbackEffect() : effect ? effect(signal) : Promise.resolve(), + }); + }, +}); + +/** 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..283250c0fb --- /dev/null +++ b/packages/runtime/src/preparation/target-identity.ts @@ -0,0 +1,51 @@ +/* + * 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)` snapshot contract shared between the filesystem Authority's +// `prepare` (capture at T0) and `PreparedOperation.execute` (re-check at run +// time). This is the coarse pre-guard against "the target was replaced while the +// call waited for the lock". It is NOT a replacement for the fd-pinned +// read-modify-write in file-stable-write.ts, which catches in-place content +// changes that leave the inode unchanged. + +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 { + /** The canonical identity string == claim.key == lock key. */ + readonly canonicalPath: string; + readonly identity: TargetIdentity; +} + +export interface ResolveIdentity { + (input: { cwd: string; path: string; semantics: 'target' | 'entry' }): Promise; +} + +/** + * True when the identity captured at prepare-time no longer matches the state + * observed at execute-time. `missing` is only stable when both sides are + * `missing` (a create target that is still absent). + */ +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..8314920025 --- /dev/null +++ b/packages/runtime/src/preparation/tool-authority-registry.ts @@ -0,0 +1,74 @@ +/* + * 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..34370c6874 --- /dev/null +++ b/packages/runtime/src/preparation/tool-preparation-service.ts @@ -0,0 +1,237 @@ +/* + * 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 { 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) {} + + 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(); + 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..28d236d96a --- /dev/null +++ b/packages/runtime/src/preparation/types.ts @@ -0,0 +1,144 @@ +/* + * 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 resource key (realpath'd path, session id, ...). + * `key` is exactly the string the authority's `PreparedOperation.execute` uses + * as its lock key — the invariant that makes claim/lock key equality hold by + * construction. + */ +export interface KeyedResourceClaim { + readonly kind: 'keyed'; + /** Domain namespace, e.g. 'filesystem:workspace-1' | 'session-todo'. */ + readonly authority: string; + /** Canonical identity string. For filesystem this is the canonical 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; +} + +/** Placeholder `all()`: conflicts with everything. Fail-closed fallback only. */ +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/tool-call-batch.ts b/packages/runtime/src/tool-call-batch.ts index d3856e87ca..1a9fdee3a1 100644 --- a/packages/runtime/src/tool-call-batch.ts +++ b/packages/runtime/src/tool-call-batch.ts @@ -17,99 +17,61 @@ * under the License. */ -import { - normalizeToolAccesses, - ToolAccesses, - type NormalizeToolAccessOptions, - type ToolAccesses as ToolAccessSet, -} from './tool-access.js'; +import type { PreparedOperation } from './preparation/types.js'; import { ToolScheduler } from './tool-scheduler.js'; export interface ToolCallBatchEntry { readonly id: string; readonly signal?: AbortSignal; - /** Omission is fail-closed and becomes ToolAccesses.all(). */ - readonly resolveAccesses?: () => Promise | ToolAccessSet | undefined; - readonly run: () => Promise | Result; + /** + * 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; } /** * 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[], - normalizeOptions: NormalizeToolAccessOptions = {}, ): Promise[]> { - const slots = entries.map((entry, index) => ({ entry, index, sequence: index })); - const prepared = await Promise.all( - slots.map(async (slot) => ({ - ...slot, - accesses: await resolveEntryAccesses(slot.entry, normalizeOptions), - })), - ); - const scheduler = new ToolScheduler(); - const resultSlots = prepared.map(({ entry, sequence, accesses }) => - scheduler.add({ - id: entry.id, - sequence, - accesses, - ...(entry.signal ? { signal: entry.signal } : {}), - run: entry.run, + 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 }; + } }), ); - return await Promise.allSettled(resultSlots); -} -async function resolveEntryAccesses( - entry: ToolCallBatchEntry, - options: NormalizeToolAccessOptions, -): Promise { - if (!entry.resolveAccesses) return ToolAccesses.all(); - try { - const declared = await resolveAccessesUntilAbort(entry); - return normalizeToolAccesses(declared ?? ToolAccesses.all(), options); - } catch { - // Access planning is a concurrency optimization and must never widen the - // set of operations allowed by ToolRuntime. A bad declaration therefore - // fails closed to global serialization while Runtime still owns the actual - // validation and model-visible error. - return ToolAccesses.all(); - } -} - -function resolveAccessesUntilAbort( - entry: ToolCallBatchEntry, -): Promise { - const planning = Promise.resolve().then(() => entry.resolveAccesses?.()); - if (!entry.signal) return planning; - if (entry.signal.aborted) return Promise.reject(abortReason(entry.signal, entry.id)); - - return new Promise((resolve, reject) => { - const onAbort = () => { - cleanup(); - reject(abortReason(entry.signal!, entry.id)); + const resultSlots = prepared.map(({ slot, operation }) => { + const runnable: PreparedOperation = operation ?? { + claims: [{ kind: 'all' }], + execute: () => slot.entry.run(undefined) as Promise, }; - const cleanup = () => entry.signal?.removeEventListener('abort', onAbort); - entry.signal!.addEventListener('abort', onAbort, { once: true }); - planning.then( - (accesses) => { - cleanup(); - resolve(accesses); - }, - (error: unknown) => { - cleanup(); - reject(error); - }, - ); - if (entry.signal!.aborted) onAbort(); - }); -} - -function abortReason(signal: AbortSignal, entryId: string): unknown { - if (signal.reason !== undefined) return signal.reason; - return Object.assign(new Error(`Tool call ${entryId} was cancelled during access planning`), { - name: 'AbortError', + return scheduler.add({ + id: slot.entry.id, + sequence: slot.sequence, + operation: runnable, + ...(slot.entry.signal ? { signal: slot.entry.signal } : {}), + run: (candidate) => slot.entry.run(candidate === runnable ? operation : undefined), + }); }); + 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 04082b4e91..18b569c813 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -142,6 +142,12 @@ 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; turnId: string; @@ -157,6 +163,13 @@ export interface ResolvedMakaToolCall { 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 { @@ -232,6 +245,8 @@ export interface MakaTool

{ * 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, @@ -879,6 +894,7 @@ 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, @@ -1137,6 +1153,7 @@ export class ToolRuntime { parentToolCallId?: string; parentOperationId?: string; maxResultBytes?: number; + effect?: PreparedToolEffect; }, stepId?: string, stepAdmission?: ToolStepAdmission, @@ -1773,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 ( diff --git a/packages/runtime/src/tool-scheduler.ts b/packages/runtime/src/tool-scheduler.ts index a2c62218bc..51ea4a14e2 100644 --- a/packages/runtime/src/tool-scheduler.ts +++ b/packages/runtime/src/tool-scheduler.ts @@ -17,14 +17,25 @@ * under the License. */ -import { toolAccessesConflict, type ToolAccesses } from './tool-access.js'; +import { claimsConflict } from './preparation/claims.js'; +import type { PreparedOperation } from './preparation/types.js'; export interface ToolSchedulerTask { readonly id: string; readonly sequence: number; - readonly accesses: ToolAccesses; + /** The prepared operation whose claims drive ordering and whose run is executed. */ + readonly operation: PreparedOperation; readonly signal?: AbortSignal; - readonly run: () => Promise | Result; + /** + * 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'; @@ -40,11 +51,16 @@ interface ScheduledTask extends ToolSchedulerTask { /** * 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) { @@ -68,6 +84,16 @@ export class ToolScheduler { 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)); @@ -104,10 +130,17 @@ export class ToolScheduler { task: ScheduledTask, candidates: readonly ScheduledTask[], ): boolean { - return candidates.some((candidate) => toolAccessesConflict(task.accesses, candidate.accesses)); + 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}`), @@ -120,7 +153,7 @@ export class ToolScheduler { let execution: Promise; try { - execution = Promise.resolve(task.run()); + execution = Promise.resolve(task.run(task.operation, task.signal)); } catch (error) { execution = Promise.reject(error); } @@ -150,12 +183,28 @@ export class ToolScheduler { } this.activeTasks.splice(index, 1); task.state = 'finished'; - if (outcome.status === 'fulfilled') task.resolve(outcome.value); - else task.reject(outcome.reason); + 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; From 7fdf9ade0af0d39d0e011a6989ace21c3749a025 Mon Sep 17 00:00:00 2001 From: jarad-z <2280669499@qq.com> Date: Fri, 4 Sep 2026 17:05:36 +0800 Subject: [PATCH 3/6] feat(runtime): add filesystem read tree leases Generated-by: Codex --- .../filesystem-read-tree-lease-test-report.md | 162 +++++++ .../filesystem-lease-composition.test.ts | 113 +++++ .../src/server/execution-composition.ts | 2 + packages/runtime/package.json | 1 + .../src/__tests__/apply-patch-batch.test.ts | 64 +++ .../builtin-tools-file-worker.test.ts | 12 +- .../src/__tests__/builtin-tools.test.ts | 11 +- .../src/__tests__/file-write-lock.test.ts | 24 + .../__tests__/filesystem-apply-patch.test.ts | 20 +- .../filesystem-authority-leases.test.ts | 424 +++++++++++++++++ .../__tests__/filesystem-authority.test.ts | 14 +- .../filesystem-lease-coordinator.test.ts | 282 ++++++++++++ .../__tests__/filesystem-lease-key.test.ts | 44 ++ .../filesystem-mutation-outcome.test.ts | 35 +- ...lesystem-tool-call-batch-scenarios.test.ts | 113 ++++- packages/runtime/src/apply-patch-batch.ts | 25 + packages/runtime/src/builtin-tools.ts | 63 +-- packages/runtime/src/file-write-lock.ts | 43 +- packages/runtime/src/filesystem-executor.ts | 429 ++++++++++++------ .../src/filesystem-lease-coordinator.ts | 205 +++++++++ packages/runtime/src/filesystem-lease-key.ts | 34 ++ packages/runtime/src/preparation/claims.ts | 20 +- .../src/preparation/target-identity.ts | 4 +- packages/runtime/src/preparation/types.ts | 9 +- packages/runtime/src/workspace-executor.ts | 5 +- 25 files changed, 1863 insertions(+), 295 deletions(-) create mode 100644 docs/filesystem-read-tree-lease-test-report.md create mode 100644 packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts create mode 100644 packages/runtime/src/__tests__/apply-patch-batch.test.ts create mode 100644 packages/runtime/src/__tests__/filesystem-authority-leases.test.ts create mode 100644 packages/runtime/src/__tests__/filesystem-lease-coordinator.test.ts create mode 100644 packages/runtime/src/__tests__/filesystem-lease-key.test.ts create mode 100644 packages/runtime/src/filesystem-lease-coordinator.ts create mode 100644 packages/runtime/src/filesystem-lease-key.ts 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__/filesystem-lease-composition.test.ts b/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts new file mode 100644 index 0000000000..b293f6f53e --- /dev/null +++ b/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { buildBuiltinTools, type BuildBuiltinToolsOptions } from '@maka/runtime/builtin-tools'; +import { createFilesystemLeaseCoordinator } from '@maka/runtime/filesystem-lease-coordinator'; +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 }); + } +}); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index cb7b2a8f6b..e8ce1f2d60 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -78,6 +78,7 @@ import { } 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 { ToolPreparationService } from '@maka/runtime/tool-preparation'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; import { isHostedExecutionTerminal } from './hosted-execution-authority.js'; @@ -449,6 +450,7 @@ export async function createExecutionRuntimeHostComposition( }, } : {}), + filesystemLeaseCoordinator: processFilesystemLeases, ...(sandboxManager ? { sandboxManager } : {}), ...(filesystemWorker ? { filesystemWorker } : {}), }; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 5d34f6e5ff..b84728a9c4 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -10,6 +10,7 @@ "./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", "./shell-tools": "./dist/shell-tools.js", "./shell-run-manager": "./dist/shell-run-manager.js", "./deep-research-tools": "./dist/deep-research-tools.js", 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-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts index b67bd1c46a..4975f034d7 100644 --- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts @@ -299,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__/file-write-lock.test.ts b/packages/runtime/src/__tests__/file-write-lock.test.ts index 15d34ca0d9..fe80b3f9b5 100644 --- a/packages/runtime/src/__tests__/file-write-lock.test.ts +++ b/packages/runtime/src/__tests__/file-write-lock.test.ts @@ -25,6 +25,8 @@ 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'; const tick = () => new Promise((r) => setImmediate(r)); @@ -86,4 +88,26 @@ 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); + }); }); 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..107f7c8037 --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-authority-leases.test.ts @@ -0,0 +1,424 @@ +/* + * 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 { 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 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..114fd9b5ee 100644 --- a/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts +++ b/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts @@ -212,15 +212,7 @@ 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 () => { + test('a queued mutation rejects a replacement before dispatching its effect', async () => { const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-t0-lockwait-'))); cleanup.push(cwd); const target = join(cwd, 'file.txt'); @@ -228,15 +220,12 @@ 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 gatedWorker: { execute: (input: FilesystemWorkerExecuteInput) => Promise; } = { @@ -246,8 +235,6 @@ describe('filesystem mutation T0 identity capture (queue-window closure)', () => 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 }; }, }; @@ -270,20 +257,14 @@ describe('filesystem mutation T0 identity capture (queue-window closure)', () => // 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. + // Release the first mutation; the second acquires the prepared key, detects + // the identity drift inside the lease, and must not dispatch. 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', - ); + await Promise.all([ + first, + assert.rejects(second, { code: 'filesystem_prepared_target_changed' }), + ]); + assert.equal(calls, 1, 'the changed queued target must never reach the raw worker'); }); test('an apply_patch mutation forwards its captured identity, not unchecked (#3484 regression)', async () => { 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 index 4b6643e079..d589472f39 100644 --- a/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts +++ b/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts @@ -40,6 +40,90 @@ afterEach(async () => { }); describe('filesystem ToolCallBatch scenarios', () => { + 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(); @@ -159,17 +243,9 @@ describe('filesystem ToolCallBatch scenarios', () => { let outcomes: Awaited | undefined; try { - await observer.waitForStarted([ - 'grep:src#1', - 'write:other/b.ts#1', - 'read:src/c.ts#1', - ]); + 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.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']); @@ -224,11 +300,7 @@ describe('filesystem ToolCallBatch scenarios', () => { 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.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']); @@ -334,7 +406,8 @@ class ControlledFilesystemWorker { maxActive = 0; async execute(input: FilesystemWorkerExecuteInput): Promise { - const base = `${input.operation.kind}:${input.operation.path.replaceAll('\\', '/')}`; + 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}`; @@ -431,10 +504,7 @@ function assertClaims( expected: Readonly>, ): void { const normalized = Object.fromEntries( - [...actual].map(([id, claims]) => [ - id, - claims.map((claim) => claimSignature(claim, cwd)), - ]), + [...actual].map(([id, claims]) => [id, claims.map((claim) => claimSignature(claim, cwd))]), ); assert.deepEqual(normalized, expected); } @@ -442,7 +512,8 @@ function assertClaims( 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 key = relative(cwd, claim.key).replaceAll('\\', '/') || '.'; + const relativeKey = relative(cwd, claim.key).replaceAll('\\', '/') || '.'; + const key = process.platform === 'win32' ? relativeKey.toLowerCase() : relativeKey; return `${claim.authority}|${claim.mode}|${claim.scope ?? 'exact'}|${key}`; } 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 514c6a2232..ff7139e1b2 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,15 +65,13 @@ import { type WorkspaceExecutor, } from './workspace-executor.js'; import { - createBoundaryFilesystemExecutor, - createFilesystemResourceAuthority, + createFilesystemResourceOwner, type FilesystemExecuteInput, + type FilesystemResourceAuthority, type FilesystemResult, } from './filesystem-executor.js'; -import { - allResourceAuthority, - noneOperation, -} from './preparation/placeholder-authorities.js'; +import type { FilesystemLeaseCoordinator } from './filesystem-lease-coordinator.js'; +import { noneOperation } from './preparation/placeholder-authorities.js'; import { defaultToolAuthorityRegistrations } from './preparation/default-tool-authorities.js'; import { ToolAuthorityRegistry, @@ -197,6 +194,8 @@ 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; /** Test/embedding override. Production callers use the current process platform. */ sandboxPlatform?: SandboxPlatform; snapshotImage?: (input: { @@ -222,18 +221,16 @@ function buildBuiltinToolDefinitions( 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 } + : {}), }); - const filesystemAuthority = includeAuthorities - ? createFilesystemResourceAuthority({ - workspace: executor, - ...(options.filesystemWorker ? { worker: options.filesystemWorker } : {}), - ...(options.permissionProfile ? { permissionProfile: options.permissionProfile } : {}), - }) - : undefined; + 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' : ''}.`; @@ -376,10 +373,7 @@ function buildBuiltinToolDefinitions( ) => | { success: true; value: unknown } | { success: false; error: unknown } - | Promise< - | { 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 }; @@ -397,17 +391,14 @@ function buildBuiltinToolDefinitions( resourceAuthority: filesystemAuthority ? { prepare: async (input, ctx) => { + if (typeof input === 'string') { + return await filesystemAuthority.preparePatchBatch(parseCodexV4aPatch(input), ctx); + } const operation = - typeof input === 'string' - ? undefined - : input && typeof input === 'object' && 'operation' in input - ? (input as { operation: { type: string; path: string; diff?: string } }) - .operation - : undefined; - // The string protocol may contain multiple filesystem operations. - // Until its parser is safely reusable during prepare, conservatively - // claim all modelled resources and execute the live tool impl once. - if (!operation) return allResourceAuthority().prepare(input, ctx); + 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, @@ -429,14 +420,10 @@ function buildBuiltinToolDefinitions( 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 AuthorityBoundMakaTool; const tools: AuthorityBoundMakaTool[] = [ @@ -916,7 +903,7 @@ function filesystemCall( * MakaTool exposed to a backend. */ function filesystemToolAuthority( - authority: ReturnType | undefined, + authority: FilesystemResourceAuthority | undefined, buildInput: (args: Args, ctx: AuthorityContext) => unknown, reshape: ( result: unknown, diff --git a/packages/runtime/src/file-write-lock.ts b/packages/runtime/src/file-write-lock.ts index 799d844017..a783dae58b 100644 --- a/packages/runtime/src/file-write-lock.ts +++ b/packages/runtime/src/file-write-lock.ts @@ -18,46 +18,25 @@ */ // 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'; /** * 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 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 9de9e2d3c2..da0a474c3b 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -43,7 +43,14 @@ 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 { identityChanged, type ResolvedTarget, @@ -113,6 +120,10 @@ export interface FilesystemApplyPatchInput extends Omit { + operations: readonly ApplyPatchOperation[]; +} + export interface ApplyPatchResult { status: 'completed'; } @@ -125,6 +136,7 @@ export interface FilesystemExecutor { */ execute(input: FilesystemExecuteInput): Promise; applyPatch(input: FilesystemApplyPatchInput): Promise; + applyPatchBatch(input: FilesystemApplyPatchBatchInput): Promise; } /** The workspace primitives the host-local backend drives. */ @@ -139,6 +151,7 @@ export interface BoundaryFilesystemExecutorInput { worker?: Pick; /** Explicit embedding policy handed to the worker instead of a mode default. */ permissionProfile?: PermissionProfile; + filesystemLeaseCoordinator?: FilesystemLeaseCoordinator; } /** @@ -154,23 +167,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's stable identity at prepare/direct-call T0, before lease + * admission. This is the inode the owner rechecks inside the lease and the + * worker compare-and-swaps against. 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 { @@ -198,8 +204,6 @@ async function captureIdentityAtLockAcquisition( * execution. This is the embedding default and deliberately the narrow one. */ interface FilesystemBackend { - execute(input: FilesystemExecuteInput): Promise; - applyPatch(input: FilesystemApplyPatchInput): Promise; run( call: FilesystemBackendExecuteInput, expectedIdentity?: FilesystemTargetIdentity, @@ -289,14 +293,11 @@ function buildFilesystemBackend(input: BoundaryFilesystemExecutorInput): Filesys ): 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); @@ -328,88 +329,25 @@ function buildFilesystemBackend(input: BoundaryFilesystemExecutorInput): Filesys inputArg.path, inputArg.semantics, ); - // Capture the target's stable identity at T0, BEFORE waiting for the lock. + // Capture the target's stable identity at T0, before entering the lease + // queue. Execute re-resolves it only after admission on this prepared key. // 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. This is the resolver the Scheduler's claim.key is derived from, - // so claim key === lock key === canonical path by construction. - const identity = await captureIdentityAtLockAcquisition( + // 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: key ?? canonicalPath, + canonicalPath, + leaseKey: hostFilesystemLeaseKey(key), identity: toTargetIdentity(identity, inputArg.semantics), }; } return { resolveTarget, run, - 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); - } - }, - 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); - } - }, - }; -} - -export function createBoundaryFilesystemExecutor( - input: BoundaryFilesystemExecutorInput, -): FilesystemExecutor { - const backend = buildFilesystemBackend(input); - return { - async execute(call) { - return await backend.execute(call); - }, - async applyPatch(call) { - return await backend.applyPatch(call); - }, }; } @@ -489,25 +427,30 @@ function toBackendOperation(target: FilesystemAuthorityInput): { return { path: operation.path, operation }; } -function toAuthorityClaims( - operation: { path: string }, - canonicalPath: string, +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, -): KeyedResourceClaim[] { - const authority = 'filesystem:workspace'; - if (isSearchOperation(target)) { - // Tree read: conflicts with any in-tree write (Grep(src) vs Write(src/a.ts)). - return [{ kind: 'keyed', authority, key: canonicalPath, mode: 'read', scope: 'tree' }]; - } - if (!isWriteOperation(target)) { - return [{ kind: 'keyed', authority, key: canonicalPath, mode: 'read' }]; - } - return [{ kind: 'keyed', authority, key: canonicalPath, mode: 'write' }]; + leaseKey: string, +): FilesystemLeaseRequest { + return { + key: leaseKey, + mode: isWriteOperation(target) ? 'write' : 'read', + scope: isSearchOperation(target) ? 'tree' : 'exact', + }; } function toBackendCall( target: FilesystemAuthorityInput, - context: AuthorityContext, + context: Pick, signal?: AbortSignal, ): FilesystemBackendExecuteInput { const { operation } = toBackendOperation(target); @@ -520,56 +463,244 @@ function toBackendCall( }; } -/** - * The filesystem domain authority. `prepare` captures the canonical identity - * (claim key == lock key == canonical path), and `execute` re-resolves that - * identity, compares it, takes the write lock, runs the effect, and settles the - * failure (path_changed / outcome_unknown). Reads and searches run unlocked. - */ -export function createFilesystemResourceAuthority( +function replaceOperationPath( + call: FilesystemBackendExecuteInput, + canonicalPath: string, +): FilesystemBackendExecuteInput { + return { ...call, operation: { ...call.operation, path: canonicalPath } }; +} + +interface PreparedFilesystemAccess { + readonly target: FilesystemAuthorityInput; + readonly semantics: 'target' | 'entry'; + readonly resolved: ResolvedTarget; + readonly lease: FilesystemLeaseRequest; +} + +export class FilesystemPreparedTargetChangedError extends Error { + override readonly name = 'FilesystemPreparedTargetChangedError'; + readonly code = 'filesystem_prepared_target_changed'; + + constructor() { + super('The approved filesystem target changed before execution.'); + } +} + +function assertSamePreparedTarget(prepared: ResolvedTarget, now: ResolvedTarget): void { + if ( + prepared.canonicalPath !== now.canonicalPath || + prepared.leaseKey !== now.leaseKey || + identityChanged(prepared.identity, now.identity) + ) { + throw new FilesystemPreparedTargetChangedError(); + } +} + +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, + resolved, + 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, -): ResourceAuthority { +): FilesystemResourceOwner { const backend = buildFilesystemBackend(input); - return { - async prepare(target, context): Promise> { - const semantics = filesystemSemantics(target); - const { path } = toBackendOperation(target); - const resourceArgs = { - cwd: context.cwd, - path, - semantics, - executionBoundary: context.executionBoundary, - permissionMode: context.permissionMode, - abortSignal: context.abortSignal, - }; - const resolved = await backend.resolveTarget(resourceArgs); - const claims = toAuthorityClaims({ path }, resolved.canonicalPath, target); - const writes = isWriteOperation(target); + const coordinator = input.filesystemLeaseCoordinator ?? processFilesystemLeases; - const execute = async (signal?: AbortSignal): Promise => { - if (!writes) { - return await backend.run(toBackendCall(target, context, signal)); - } - const now = await backend.resolveTarget({ - ...resourceArgs, - abortSignal: signal ?? context.abortSignal, - }); - if (identityChanged(resolved.identity, now.identity)) { - // business failure -> fulfilled error result, not a fatal rejection. - throw new Error('The approved filesystem target changed before execution.'); - } - const expectedIdentity = toExpectedIdentity(now.identity); + const executeAccess = async ( + access: PreparedFilesystemAccess, + context: Pick, + signal?: AbortSignal, + ): Promise => { + const abortSignal = signal ?? context.abortSignal; + return await coordinator.withLease(access.lease, abortSignal, async () => { + const now = await backend.resolveTarget(resourceArgsFor(access.target, context, signal)); + assertSamePreparedTarget(access.resolved, now); + const call = replaceOperationPath( + toBackendCall(access.target, context, signal), + now.canonicalPath, + ); + try { + return await backend.run( + call, + access.lease.mode === 'write' ? toExpectedIdentity(now.identity) : undefined, + ); + } catch (error) { + throw access.lease.mode === 'write' ? settleMutationFailure(error) : error; + } + }); + }; + + 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 PreparedFilesystemAccess[], + context: Pick, + signal?: AbortSignal, + ): Promise => { + const requests = normalizeFilesystemLeaseRequests(accesses.map((access) => access.lease)); + const abortSignal = signal ?? context.abortSignal; + return await coordinator.withLeases(requests, abortSignal, async () => { + const preflight: ResolvedTarget[] = []; + for (let index = 0; index < accesses.length; index += 1) { + const access = accesses[index]!; try { - return await withFileWriteLock(now.canonicalPath, () => - backend.run(toBackendCall(target, context, signal), expectedIdentity), - ); + const now = await backend.resolveTarget(resourceArgsFor(access.target, context, signal)); + assertSamePreparedTarget(access.resolved, now); + preflight.push(now); } catch (error) { - throw settleMutationFailure(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)}`, + }; } - }; - return oneShotOperation({ claims, execute }); + } + + let operationIndex = 0; + const seenLeaseKeys = new Set(); + const operations = accesses.map( + (access) => (access.target as FilesystemApplyPatchInput).operation, + ); + return await executeApplyPatchOperations( + operations, + async (operation) => { + const index = operationIndex++; + const access = accesses[index]!; + let now = preflight[index]!; + if (seenLeaseKeys.has(access.lease.key)) { + now = await backend.resolveTarget(resourceArgsFor(access.target, context, signal)); + } + seenLeaseKeys.add(access.lease.key); + const call = replaceOperationPath( + toBackendCall({ operation, ...context }, context, signal), + now.canonicalPath, + ); + try { + const result = await backend.run(call, toExpectedIdentity(now.identity)); + if (result.kind !== 'apply_patch') { + throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`); + } + } catch (error) { + throw settleMutationFailure(error); + } + }, + abortSignal, + ); + }); + }; + + 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) { + const context = directContext(call); + const access = await prepareFilesystemAccess(backend, call, context); + return await executeAccess(access, context, call.abortSignal); + }, + async applyPatch(call) { + 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; } /** 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/preparation/claims.ts b/packages/runtime/src/preparation/claims.ts index efd76e0212..6787f7f9a7 100644 --- a/packages/runtime/src/preparation/claims.ts +++ b/packages/runtime/src/preparation/claims.ts @@ -69,15 +69,23 @@ function keyedClaimsConflict(a: KeyedResourceClaim, b: KeyedResourceClaim): bool } function keyedKeysOverlap(a: KeyedResourceClaim, b: KeyedResourceClaim): boolean { - if (a.key === b.key) return true; - const aTree = a.scope === 'tree'; - const bTree = b.scope === 'tree'; - if (aTree && isWithin(a.key, b.key)) return true; - if (bTree && isWithin(b.key, a.key)) return true; + 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; } -function isWithin(parent: string, candidate: string): boolean { +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 diff --git a/packages/runtime/src/preparation/target-identity.ts b/packages/runtime/src/preparation/target-identity.ts index 283250c0fb..4830986015 100644 --- a/packages/runtime/src/preparation/target-identity.ts +++ b/packages/runtime/src/preparation/target-identity.ts @@ -31,8 +31,10 @@ export type TargetIdentity = | { readonly kind: 'missing' }; export interface ResolvedTarget { - /** The canonical identity string == claim.key == lock key. */ + /** Backend-executable canonical path. */ readonly canonicalPath: string; + /** Platform-normalized Scheduler/coordinator key. Never send to a backend. */ + readonly leaseKey: string; readonly identity: TargetIdentity; } diff --git a/packages/runtime/src/preparation/types.ts b/packages/runtime/src/preparation/types.ts index 28d236d96a..36e23b69db 100644 --- a/packages/runtime/src/preparation/types.ts +++ b/packages/runtime/src/preparation/types.ts @@ -40,16 +40,15 @@ export type KeyedClaimMode = 'read' | 'write' | 'exclusive'; export type KeyedClaimScope = 'exact' | 'tree'; /** - * A claim on one canonical resource key (realpath'd path, session id, ...). - * `key` is exactly the string the authority's `PreparedOperation.execute` uses - * as its lock key — the invariant that makes claim/lock key equality hold by - * construction. + * 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 identity string. For filesystem this is the canonical path. */ + /** Canonical coordination identity. It need not be a backend-executable path. */ readonly key: string; readonly mode: KeyedClaimMode; readonly scope?: KeyedClaimScope; diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 6fa76ee09f..71c9df4f6c 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -191,7 +191,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 { @@ -436,7 +439,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 { From 679457d910bba8d9a0e4171b95aa4eee8b1d229b Mon Sep 17 00:00:00 2001 From: jarad-z <2280669499@qq.com> Date: Fri, 4 Sep 2026 17:49:05 +0800 Subject: [PATCH 4/6] test(runtime): pin prepared effect durable identity Generated-by: Codex --- ...ool-authority-kimi-semantics-batch.test.ts | 26 ++++-------- .../__tests__/tool-runtime-settlement.test.ts | 41 +++++++++++++++++++ .../src/preparation/one-shot-operation.ts | 4 +- .../preparation/tool-authority-registry.ts | 4 +- 4 files changed, 51 insertions(+), 24 deletions(-) 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 index 2b41904ad9..0170f9f4f9 100644 --- a/packages/runtime/src/__tests__/tool-authority-kimi-semantics-batch.test.ts +++ b/packages/runtime/src/__tests__/tool-authority-kimi-semantics-batch.test.ts @@ -51,7 +51,10 @@ describe('Kimi claim predicate', () => { 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: '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); }); @@ -73,10 +76,7 @@ describe('Kimi claim predicate', () => { 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 h = harness([call('update_agent_graph', 'all'), call('Read', 'read', 'a')]); const batch = h.run(); await h.waitStarted('all'); await h.expectStarted('all'); @@ -87,10 +87,7 @@ describe('Kimi ToolCallBatch semantics', () => { }); test('B02: all does not block none', async () => { - const h = harness([ - call('update_agent_graph', 'all'), - call('WebSearch', 'web'), - ]); + 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'); @@ -142,11 +139,7 @@ describe('Kimi ToolCallBatch semantics', () => { call('agent_output', 'agent-output'), ]); const batch = h.run(); - await Promise.all([ - h.waitStarted('web'), - h.waitStarted('all'), - h.waitStarted('agent-output'), - ]); + 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'); @@ -306,10 +299,7 @@ function harness(specs: readonly CallSpec[]) { const registry = new ToolAuthorityRegistry([ ['Read', exactAuthority('read')], ['Write', exactAuthority('write')], - [ - 'BrokenPreparedTool', - { prepare: async () => Promise.reject(new Error('broken prepare')) }, - ], + ['BrokenPreparedTool', { prepare: async () => Promise.reject(new Error('broken prepare')) }], ]).withRegistrations(defaultToolAuthorityRegistrations()); const service = new ToolPreparationService(registry); const context = (id: string): MakaToolContext => ({ diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index e625722972..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 = { diff --git a/packages/runtime/src/preparation/one-shot-operation.ts b/packages/runtime/src/preparation/one-shot-operation.ts index 06241b70fd..3ac3dc65e6 100644 --- a/packages/runtime/src/preparation/one-shot-operation.ts +++ b/packages/runtime/src/preparation/one-shot-operation.ts @@ -61,9 +61,7 @@ export function oneShotOperation( state = 'running'; let execution: Promise; try { - execution = Promise.resolve( - operation.execute(signal, fallbackEffect, executionContext), - ); + execution = Promise.resolve(operation.execute(signal, fallbackEffect, executionContext)); } catch (error) { execution = Promise.reject(error); } diff --git a/packages/runtime/src/preparation/tool-authority-registry.ts b/packages/runtime/src/preparation/tool-authority-registry.ts index 8314920025..2794ee9a47 100644 --- a/packages/runtime/src/preparation/tool-authority-registry.ts +++ b/packages/runtime/src/preparation/tool-authority-registry.ts @@ -62,9 +62,7 @@ export class ToolAuthorityRegistry { * the supplied registrations. The constructor remains the single duplicate * check, so a policy cannot silently replace a domain authority. */ - withRegistrations( - registrations: Iterable, - ): ToolAuthorityRegistry { + withRegistrations(registrations: Iterable): ToolAuthorityRegistry { return new ToolAuthorityRegistry([...this.#authorities, ...registrations]); } From 6988ac63accddc0621695d1fc1eb2e8788cee1a5 Mon Sep 17 00:00:00 2001 From: jarad-z <2280669499@qq.com> Date: Fri, 4 Sep 2026 17:54:31 +0800 Subject: [PATCH 5/6] docs(runtime): remove superseded scheduler design Generated-by: Codex --- tool-runtime-task-scheduler-architecture.md | 978 -------------------- 1 file changed, 978 deletions(-) delete mode 100644 tool-runtime-task-scheduler-architecture.md diff --git a/tool-runtime-task-scheduler-architecture.md b/tool-runtime-task-scheduler-architecture.md deleted file mode 100644 index 891192979e..0000000000 --- a/tool-runtime-task-scheduler-architecture.md +++ /dev/null @@ -1,978 +0,0 @@ - - -# ToolRuntime 基于 Task 与 Access 的资源感知调度架构 - -## 1. 文档状态 - -- 状态:架构设计提案 -- 范围:同一个 assistant step 返回的本地 Tool Call batch -- 目标:在保留无冲突工具并发能力的同时,避免共享资源上的读写竞态,并确保 Tool Result 按原始 Tool Call 顺序返回 -- 关联 Issue:[`apache/maka#4487`](https://github.com/apache/maka/issues/4487) - -## 2. 背景 - -当前 Runtime 使用 `Promise.allSettled()` 并发消费一个 assistant step 中的本地 Tool Call: - -```text -ToolCalls - ↓ -returnedToolCalls.map(settleToolCall) - ↓ -Promise.allSettled - ↓ -下一次 LLM step -``` - -该模型能够实现 fan-out/fan-in,但除 `exclusive_step` 外,Runtime 不理解不同调用访问的共享资源。两个不依赖彼此返回值的 Tool Call,仍可能竞争同一文件、Session 状态、终端、浏览器标签页或远端服务。 - -本方案把每个 Tool Call 封装成一个 `ToolCallTask`。工具负责根据本次调用参数声明 `accesses`,Scheduler 根据资源冲突关系决定 Task 的启动时间,Batch Runner 最后按 Tool Call 原始顺序组装 Tool Results。 - -## 3. 设计目标 - -1. 每个 Tool Call 对应一个可独立调度、且最多启动一次的 Task。 -2. 资源不冲突的 Task 尽早并发执行。 -3. 资源冲突的 Task 按模型生成顺序执行。 -4. 后到的非冲突 Task 可以越过正在等待的 Task。 -5. 防止持续到来的只读 Task 越过已经排队的写 Task。 -6. Task 的调度顺序、完成顺序和 Tool Result 返回顺序相互解耦。 -7. 工具未声明资源范围时采用保守的 fail-closed 策略。 -8. 保留现有 durable settlement、事件发布、取消和 Turn 级错误边界。 - -## 4. 非目标 - -第一阶段不解决以下问题: - -- 不推导 Bash 命令的精确副作用集合。 -- 不保证不同 Turn、Session、Agent 或 Runtime 进程之间的资源互斥。 -- 不为整个 batch 构建静态 DAG。 -- 不改变模型侧 Tool Call/Tool Result 协议。 -- 不用 Scheduler 替代参数校验、权限判断、sandbox 或持久化逻辑。 - -## 5. 核心设计决策 - -### 5.1 Access 是调用级数据 - -Access 由工具根据本次 Tool Call 的参数生成,而不是 Tool 的静态属性,也不由模型直接生成。 - -```text -Write({ path: "a.ts" }) → writeFile(a.ts) -Write({ path: "b.ts" }) → writeFile(b.ts) -``` - -### 5.2 一个 Task 包含多个 Access - -字段使用 `accesses` 而不是单数 `access`。一个 Task 可能同时访问多个资源,例如: - -```text -Copy(source, target) - → read(source) - → write(target) -``` - -Task 只有在全部 accesses 都可用时才能启动,不允许先占用部分资源、再等待其余资源。 - -### 5.3 Scheduler 不理解具体 Tool - -领域层 Task 可以保存 Tool 对象用于执行和诊断,但 Scheduler 只能依赖: - -- Task 顺序号; -- 标准化后的 accesses; -- 唯一的执行入口 `run()`。 - -Scheduler 不允许根据 `tool.name` 或原始参数编写特殊分支。 - -### 5.4 未声明 Access 时默认 `all` - -只有能够证明资源范围的工具才能声明精确 access;只有能够证明不访问 Scheduler 管理资源的工具才能声明 `none`。 - -```text -显式声明 accesses → 使用声明值 -未声明 accesses → all -不执行真实副作用 → none -``` - -### 5.5 `exclusive_step` 独立于资源调度 - -`exclusive_step` 是控制面和因果边界;`all` 是数据面资源互斥: - -- `all` 可以在当前 batch 内等待并执行; -- `exclusive_step` 要求独占 assistant step,冲突调用应保持现有 admission rejection/synthetic result 语义。 - -因此不能把 `exclusive_step` 简化为 `accesses: all()`。 - -## 6. 总体架构 - -```text -LLM 返回有序 ToolCalls - │ - ▼ -┌─────────────────────────────┐ -│ ToolCallBatchRunner │ -│ 分配固定 index/sequence │ -└──────────────┬──────────────┘ - ▼ -┌─────────────────────────────┐ -│ BatchAdmission │ -│ exclusive_step / step 边界 │ -└──────────────┬──────────────┘ - ▼ -┌─────────────────────────────┐ -│ ToolTaskFactory │ -│ 查找 Tool │ -│ 校验并规范化参数 │ -│ resolveExecution() │ -│ 生成 accesses 与 run() │ -└──────────────┬──────────────┘ - ▼ -┌─────────────────────────────┐ -│ ToolScheduler │ -│ 无冲突 Task 并发 │ -│ 冲突 Task 按 sequence 排队 │ -└──────────────┬──────────────┘ - ▼ - Task 可以乱序完成 - │ - ▼ -┌─────────────────────────────┐ -│ ToolResultAssembler │ -│ 按原始 index 回填结果 │ -└──────────────┬──────────────┘ - ▼ - 有序 ToolResults[] -``` - -### 6.1 对象调用图 - -下面的时序图描述新方案中各对象的调用关系。实线表示调用,虚线表示返回;Task 的实际完成顺序可以与提交顺序不同。 - -```mermaid -sequenceDiagram - autonumber - participant Provider as Model Provider - participant Runner as ToolCallBatchRunner - participant Admission as BatchAdmission - participant Factory as ToolTaskFactory - participant Registry as ToolRegistry - participant Tool as MakaTool - participant Scheduler as ToolScheduler - participant Task as ToolCallTask - participant Runtime as ToolRuntime - participant Impl as Tool Implementation - participant Assembler as ToolResultAssembler - participant NextStep as Next LLM Step - - Provider->>Runner: consume(toolCalls, turnContext) - Runner->>Runner: assign index and sequence before async work - Runner->>Admission: admit(toolCalls, stepState) - Admission-->>Runner: admitted calls and synthetic result slots - - loop Each admitted Tool Call - Runner->>Factory: prepare(slot, turnContext) - Factory->>Registry: get(toolCall.toolName) - Registry-->>Factory: MakaTool - Factory->>Tool: resolveExecution(input, toolContext) - Tool-->>Factory: ToolExecution(accesses?, execute) - Factory->>Factory: normalize accesses or default to all() - Factory-->>Runner: ToolCallTask(sequence, accesses, run) - end - - Note over Runner: Preparation barrier preserves original submission order - - loop Submit tasks by sequence - Runner->>Scheduler: add(task) - alt Conflicts with active or earlier queued task - Scheduler->>Scheduler: enqueue(task) - Scheduler-->>Runner: pending result Promise - else Runnable now - Scheduler->>Scheduler: mark task active - Scheduler->>Task: run(abortSignal) asynchronously - Scheduler-->>Runner: pending result Promise - end - end - - Task->>Runtime: settleToolCall(tool, input, context) - Runtime->>Runtime: admission, validation, permission, T1 dispatch - Runtime->>Impl: tool.impl(input, toolContext) - Impl-->>Runtime: raw result or business error - Runtime->>Runtime: normalize result and persist T2 outcome - Runtime-->>Task: ToolSettlement - Task-->>Scheduler: result settled - Scheduler->>Scheduler: finishTask() and release accesses - Scheduler->>Scheduler: drainQueue() from front to back - Scheduler->>Task: run() for newly unblocked tasks - - Runner->>Runner: await Promise.allSettled(result slots) - Runner->>Assembler: assemble(toolCalls, outcomes) - Assembler->>Assembler: pair by index and toolCallId - Assembler-->>Runner: ordered ToolResults[] - Runner->>NextStep: continue with complete batch results -``` - -关键对象调用链: - -```text -ToolCallBatchRunner - → BatchAdmission - → ToolTaskFactory - → ToolRegistry - → MakaTool.resolveExecution() - → ToolScheduler.add() - → ToolCallTask.run() - → ToolRuntime.settleToolCall() - → Tool implementation - → finishTask() - → drainQueue() - → ToolResultAssembler.assemble() - → Next LLM Step -``` - -其中,`ToolScheduler.add()` 返回的是与原始 result slot 绑定的 Promise。Scheduler 只控制 `run()` 何时被调用;`ToolResultAssembler` 不读取 Scheduler 的完成顺序,只按照预先分配的 `index/sequence` 回填结果。 - -## 7. 组件职责 - -### 7.1 ToolCallBatchRunner - -负责一个 Tool Call batch 的完整生命周期: - -1. 保存 Provider 返回的 Tool Call 顺序。 -2. 在任何异步工作之前分配 `index` 和 `sequence`。 -3. 执行 step admission。 -4. 调用 `ToolTaskFactory` 准备 Task。 -5. 按原始顺序向 Scheduler 提交 Task。 -6. 等待整批 Task settle。 -7. 调用 `ToolResultAssembler` 生成有序结果。 - -### 7.2 ToolTaskFactory - -负责把一个具体 Tool Call 转换成执行计划: - -1. 根据名称查找 Tool。 -2. 校验和解析参数。 -3. 把路径、Session ID、Tab ID 等转换为稳定资源标识。 -4. 调用 Tool 的 `resolveExecution()`。 -5. 对未声明的 accesses 补充 `all()`。 -6. 构造只允许启动一次的 `run()`。 -7. 对工具不存在、参数错误、hook 阻止等情况创建 resolved Task。 - -### 7.3 ToolScheduler - -只回答一个问题:一个已经准备完成的 Task 现在能否启动? - -它不负责: - -- Tool 参数校验; -- 权限审批; -- sandbox; -- Tool Result 格式化; -- RuntimeEvent 持久化; -- Tool Result 的最终返回顺序。 - -### 7.4 ToolRuntime - -保留单次调用的可靠执行边界: - -```text -run() - → executeTool()/settleToolCall() - → 权限与可用性检查 - → T1 durable dispatch - → tool_start - → tool.impl() - → 结果归一化 - → T2 durable outcome - → tool_result - → ToolSettlement -``` - -Scheduler 调度的是整个 settlement,而不是绕过 ToolRuntime 直接调用 `tool.impl()`。 - -### 7.5 ToolResultAssembler - -负责把 Task outcome 转换成模型协议需要的 Tool Result,并保证: - -- 每个原始 Tool Call 恰好对应一个结果槽位; -- 最终数组按原始 Tool Call 顺序排列; -- Tool Call ID 与 Tool Result ID 正确配对; -- 基础设施级失败不会被误包装成普通业务错误。 - -## 8. 领域模型 - -### 8.1 ToolCallTask - -```ts -interface ToolCallTask { - readonly id: string; - readonly sequence: number; - readonly toolCall: ToolCallPart; - readonly tool: MakaTool; - readonly input: unknown; - readonly accesses: ToolAccesses; - readonly run: (signal: AbortSignal) => Promise; -} -``` - -`tool` 可以被 Task 保存,但 Scheduler 不直接读取它。 - -### 8.2 SchedulerTask - -```ts -interface SchedulerTask { - readonly id: string; - readonly sequence: number; - readonly accesses: ToolAccesses; - readonly run: () => Promise; -} - -interface ScheduledTask extends SchedulerTask { - state: "queued" | "active" | "finished"; - readonly result: Promise; -} -``` - -合法状态转换: - -```text -new → queued → active → finished -new → active → finished -``` - -禁止: - -- `active → queued`; -- `finished → active`; -- 同一个 Task 同时存在于 active 和 queued; -- 同一个 Task 多次调用 `run()`。 - -## 9. Resource Access 模型 - -### 9.1 类型定义 - -```ts -type ToolAccesses = readonly ResourceAccess[]; - -type ResourceAccess = - | { - readonly kind: "file"; - readonly path: string; - readonly operation: "read" | "search" | "write" | "readwrite"; - readonly recursive?: boolean; - } - | { - readonly kind: "key"; - readonly key: string; - readonly operation: "read" | "write"; - } - | { - readonly kind: "all"; - }; -``` - -### 9.2 特殊集合 - -```ts -ToolAccesses.none() // [] -ToolAccesses.all() // [{ kind: "all" }] -``` - -- `none`:不访问当前 Scheduler 建模的共享资源。 -- `all`:资源范围未知,与任意非空 accesses 冲突。 -- `all` 与 `none` 不冲突,因为 `none` 不占用资源。 - -`none` 不等于“工具没有任何外部副作用”,只表示它不访问当前 Scheduler 管理的资源。Web 请求的连接数、QPS 和预算应由独立的容量控制处理。 - -### 9.3 文件路径规范化 - -文件 access 进入 Scheduler 前必须完成: - -- 转换为绝对路径; -- 消解 `.` 和 `..`; -- 统一路径分隔符; -- 去除无意义的尾部分隔符; -- 按平台决定大小写敏感性; -- 明确单文件或递归目录范围。 - -Scheduler 的冲突判断不得执行文件系统 I/O。符号链接和 junction 如需归并,应在 Task 准备阶段生成 canonical resource identity。 - -### 9.4 逻辑资源 Key - -非文件资源使用带命名空间的稳定 key: - -```text -session:{sessionId}:todo -session:{sessionId}:goal -execution:{executionId}:plan -terminal:{sessionId}:{ref} -browser:{browserSessionId}:tab:{tabId} -computer:{deviceId}:window:{windowId} -mcp:{serverId}:session:{sessionId} -``` - -Key 的生成属于 ToolTaskFactory 或具体 Tool,不属于 Scheduler。 - -## 10. 冲突模型 - -### 10.1 Task 级冲突 - -两个 Task 的 accesses 做笛卡尔积比较,只要存在一对资源 access 冲突,两个 Task 就冲突: - -```ts -function tasksConflict(left: ToolAccesses, right: ToolAccesses): boolean { - return left.some(a => right.some(b => accessesConflict(a, b))); -} -``` - -### 10.2 读写冲突 - -| 左 / 右 | read | search | write | readwrite | -|---|---:|---:|---:|---:| -| read | 否 | 否 | 是 | 是 | -| search | 否 | 否 | 是 | 是 | -| write | 是 | 是 | 是 | 是 | -| readwrite | 是 | 是 | 是 | 是 | - -只有操作类型可能冲突且资源范围重叠时,才构成实际冲突。 - -### 10.3 文件范围重叠 - -以下任一条件成立即为重叠: - -1. 两个标准化路径完全相同。 -2. 左侧为递归访问,右侧位于左侧目录树内。 -3. 右侧为递归访问,左侧位于右侧目录树内。 - -父子关系必须按照路径分段判断: - -```text -/repo/src 是 /repo/src/a.ts 的父目录 -/repo/src 不是 /repo/src2/a.ts 的父目录 -``` - -冲突函数必须满足对称性: - -```text -conflict(A, B) == conflict(B, A) -``` - -## 11. Access 生成协议 - -建议扩展 Tool contract: - -```ts -interface ToolExecution { - readonly accesses?: ToolAccesses; - readonly execute: () => Promise; -} - -interface MakaTool { - resolveExecution( - input: Input, - context: ToolContext, - ): ToolExecution | Promise>; -} -``` - -Task Factory 的兜底规则: - -```ts -const execution = await tool.resolveExecution(input, context); -const accesses = execution.accesses ?? ToolAccesses.all(); -``` - -工具不存在、参数校验失败、被 hook 阻止、admission 拒绝或已经产生 synthetic result 时,不会执行真实副作用,应创建 `none()` Task 或直接创建 resolved result slot。 - -## 12. 推荐的工具映射 - -| 工具类别 | 建议 Access | -|---|---| -| `Read` / `ReadMediaFile` | `readFile(resolvedPath)` | -| `Write` | `writeFile(resolvedPath)` | -| `Edit` / `FormatJson` | `readWriteFile(resolvedPath)` | -| `Glob` / `Grep` | `searchTree(resolvedRootOrWorkspace)` | -| `apply_patch` | 补丁涉及的所有文件 `writeFile` | -| `Bash` | 默认 `all()`;后续允许调用方声明精确资源 | -| `todo_read` | `read(session:{id}:todo)` | -| `todo_write` | `write(session:{id}:todo)` | -| Goal 查询 | `read(session:{id}:goal)` | -| Goal 修改 | `write(session:{id}:goal)` | -| Plan 查询 | `read(execution:{id}:plan)` | -| Plan 修改 | `write(execution:{id}:plan)` | -| Terminal mutation | `write(terminal:{sessionId}:{ref})` | -| Browser mutation | `write(browser:{sessionId}:tab:{tabId})` | -| Computer mutation | `write(computer:{deviceId}:window:{windowId})` | -| WebSearch / WebFetch | `none()`,另设 provider 容量限制 | -| MCP read-only | server 容量限制内的 read key | -| MCP unknown/mutation | server/session/resource write key,无法确定则 `all()` | -| synthetic result | `none()` | - -## 13. Scheduler 算法 - -### 13.1 状态 - -```ts -activeTasks: ScheduledTask[]; -queuedTasks: ScheduledTask[]; -nextSequence: number; -``` - -### 13.2 阻塞条件 - -```ts -function isBlocked( - task: ScheduledTask, - active: readonly ScheduledTask[], - queuedBefore: readonly ScheduledTask[], -): boolean { - return ( - conflictsWithAny(task, active) || - conflictsWithAny(task, queuedBefore) - ); -} -``` - -检查 active 保证资源安全;检查前序 queued 保证冲突顺序和 writer 公平性。 - -示例: - -```text -active: R1 = read(a) -queued: W = write(a) -new: R2 = read(a) -``` - -虽然 R2 不与 R1 冲突,但它与更早排队的 W 冲突,所以 R2 必须排在 W 后面。否则持续到来的 reader 会导致 writer starvation。 - -### 13.3 添加 Task - -```ts -function add(task: SchedulerTask): Promise { - const scheduled = createScheduledTask(task); - - if (isBlocked(scheduled, activeTasks, queuedTasks)) { - queuedTasks.push(scheduled); - } else { - startTask(scheduled); - } - - return scheduled.result; -} -``` - -新 Task 可以越过前序 queued Task,但前提是二者不存在资源冲突。 - -### 13.4 启动 Task - -启动前必须先将 Task 放入 active,确保同步到来的下一次 `add()` 能观察到资源已经被占用: - -```ts -function startTask(task: ScheduledTask): void { - assert(task.state === "queued"); - task.state = "active"; - activeTasks.push(task); - - let started: Promise; - try { - started = Promise.resolve(task.run()); - } catch (error) { - started = Promise.reject(error); - } - - void started - .then(task.resolve, task.reject) - .finally(() => finishTask(task)); -} -``` - -同步抛错也必须进入统一的异步完成路径,避免同步重入导致队列状态损坏。 - -### 13.5 完成和重扫 - -```ts -function finishTask(task: ScheduledTask): void { - if (task.state !== "active") return; - - remove(activeTasks, task); - task.state = "finished"; - drainQueue(); -} -``` - -队列从前向后重扫: - -```ts -function drainQueue(): void { - const stillQueued: ScheduledTask[] = []; - - for (const task of queuedTasks) { - if (isBlocked(task, activeTasks, stillQueued)) { - stillQueued.push(task); - } else { - startTask(task); - } - } - - queuedTasks = stillQueued; -} -``` - -一次重扫可以启动多个互不冲突的 Task,不应只消费队头一个 Task。 - -### 13.6 调度示例 - -按顺序提交: - -```text -T1 = read(a) -T2 = write(a) -T3 = read(a) -T4 = write(b) -``` - -提交后: - -```text -T1:启动 -T2:与 T1 冲突,排队 -T3:与前序 queued T2 冲突,排队 -T4:与 active 和 queued 均不冲突,启动 - -active = [T1, T4] -queued = [T2, T3] -``` - -T1 完成后: - -```text -T2:启动 -T3:与刚启动的 T2 冲突,继续等待 -``` - -T2 完成后,T3 启动。 - -## 14. 顺序保证 - -必须区分三种顺序: - -```text -模型生成顺序 ≠ Task 完成顺序 ≠ 实时事件顺序 -``` - -### 14.1 Sequence 分配 - -`sequence` 必须在任何异步准备工作之前,按照 Provider 返回数组的 index 分配: - -```ts -const slots = toolCalls.map((toolCall, index) => ({ - index, - sequence: index, - toolCall, -})); -``` - -如果 `resolveExecution()` 是异步的,不能按照“准备完成顺序”提交 Scheduler,否则资源冲突 Task 的先后关系会偏离模型生成顺序。 - -第一版采用 preparation barrier: - -1. 先创建所有有固定 index 的 slot。 -2. 可以并发准备 execution plan。 -3. 等所有 plan 准备完成。 -4. 严格按 index 调用 `scheduler.add()`。 - -### 14.2 Result Slot - -每个原始 Tool Call 始终保留一个 pending result slot: - -```ts -const pendingResults = preparedSlots.map(slot => { - if (slot.syntheticResult) { - return Promise.resolve(slot.syntheticResult); - } - - return scheduler.add(slot.task); -}); -``` - -### 14.3 有序组装 - -```ts -const outcomes = await Promise.allSettled(pendingResults); - -const toolResults = outcomes.map((outcome, index) => - toToolResult(toolCalls[index], outcome), -); -``` - -`Promise.allSettled()` 允许 Task 乱序完成,但返回数组仍与输入 Promise 保持相同索引。 - -实时 `tool_start`、`tool_result` 事件可以按实际发生顺序发布;事件必须携带 `toolCallId` 和 `sequence`,不能依赖事件抵达顺序完成配对。 - -## 15. `exclusive_step` Admission - -资源调度前保留现有 step admission: - -```text -ToolCalls - ↓ -exclusive_step admission - ├─ admitted → 准备并执行 - └─ rejected → synthetic Tool Result -``` - -建议第一阶段保持既有行为,避免把资源调度改造与控制面语义变更混在一起: - -- `exclusive_step` 作为首个被接纳调用时执行,后续冲突调用被拒绝; -- 普通调用已经被接纳后遇到 `exclusive_step`,该 exclusive 调用被拒绝; -- admission rejection 不执行真实副作用,使用 `none()` 或直接 resolved slot; -- `AskUserQuestion`、权限请求和 `SubmitPlan` 等控制工具继续通过此机制形成明确的 step 边界。 - -## 16. 失败语义 - -### 16.1 模型可见失败 - -以下失败应归一化为正常 Tool Result,Task Promise 可以 fulfilled: - -- 参数错误; -- 工具不存在; -- 权限被拒绝; -- admission 被拒绝; -- Tool 业务错误; -- 可确认没有产生不确定副作用的执行失败。 - -### 16.2 Turn 级失败 - -以下失败不得伪装成普通 Tool Result: - -- T1/T2 durable commit 失败; -- 无法判断外部副作用是否已经发生; -- Runtime ledger 或事件一致性被破坏; -- Scheduler 内部不变量被破坏。 - -Batch Runner 使用 `Promise.allSettled()` 等待全部 Task 进入终态后,再把基础设施级 rejection 提升为 Turn 级错误。 - -一个普通 Tool 失败不会自动取消同批其他 Task。 - -## 17. 取消和超时 - -所有 Task 共享 Turn 的 abort signal,但 queued 和 active Task 的处理不同: - -### 17.1 Queued Task - -- abort 后不得调用 `run()`; -- 必须从 queued 中移除; -- result Promise 必须 settle,不能永久悬挂; -- 根据 Turn 协议转换为 cancellation result 或 rejection。 - -### 17.2 Active Task - -- 把 abort signal 传递给 ToolRuntime 和 Tool 实现; -- Tool 实现应尽快终止可取消操作; -- 无论成功、失败还是取消,最终都必须释放 active 状态并触发 `drainQueue()`。 - -## 18. 容量限制 - -资源冲突和容量限制是两个不同问题: - -- 资源冲突回答“两个 Task 能否安全地同时运行”; -- 容量限制回答“系统当前最多允许多少个 Task 同时运行”。 - -不要通过伪造资源冲突表达 API QPS、进程数或连接数限制。建议为 Scheduler 或外围 Coordinator 增加独立 capacity policy: - -```ts -interface CapacityRequest { - readonly key: string; - readonly units?: number; -} -``` - -典型 key: - -```text -provider:web-search -mcp-server:{serverId} -subagent-spawn -process:workspace:{workspaceId} -``` - -第一阶段可以只实现资源冲突,容量限制作为后续扩展。 - -## 19. Scheduler 生命周期和协调范围 - -第一阶段采用 batch-local Scheduler: - -```text -一个 assistant step - → 一个 Tool Call batch - → 一个 ToolScheduler - → batch 完成后销毁 -``` - -它能够解决同一 batch 内的竞态,但不能阻止以下跨边界冲突: - -- 两个并行 Turn 修改同一 workspace 文件; -- 父 Agent 与子 Agent 修改同一资源; -- 不同 Runtime 进程操作同一终端或浏览器会话。 - -如果未来需要跨 batch 保证,应抽取共享 `ResourceCoordinator`: - -```text -Batch ToolScheduler - ↓ -Workspace/Session ResourceCoordinator - ↓ -Runtime Host -``` - -共享 Coordinator 可以复用相同的 `ResourceAccess` 和冲突模型,但它需要额外处理租约、进程退出、恢复和跨进程一致性,不属于第一阶段范围。 - -## 20. 可观察性 - -建议为每个 Task 记录: - -- `toolCallId`; -- `sequence`; -- Tool 名称; -- accesses 摘要; -- `queuedAt`; -- `startedAt`; -- `finishedAt`; -- queue wait duration; -- execution duration; -- blocking task/resource; -- settlement 类型。 - -推荐事件: - -```text -tool_task_prepared -tool_task_queued -tool_task_started -tool_task_finished -tool_task_cancelled -``` - -`queued` 是调度状态,不应被包装成最终 Tool Result。模型最终只应看到执行结果、业务失败、admission rejection 或取消结果。 - -## 21. 必须保持的不变量 - -1. `activeTasks` 中任意两个 Task 都不冲突。 -2. 同一个 Task 最多调用一次 `run()`。 -3. 同一个 Task 不会同时存在于 active 和 queued。 -4. finished Task 不再存在于 active 或 queued。 -5. 后到的 Task 不会越过与它冲突的前序 queued Task。 -6. 非冲突 Task 不会仅因为队列非空而等待。 -7. Task 成功、失败、取消或同步抛错后都会释放 active 状态。 -8. 一次资源释放后,所有当前满足条件的 Task 都会被启动。 -9. Task result Promise 最终只 settle 一次。 -10. Scheduler 内部不会产生 detached unhandled rejection。 -11. 每个原始 Tool Call 恰好对应一个最终 result slot。 -12. ToolResults 的最终顺序与原始 Tool Calls 顺序一致。 - -## 22. 验收测试 - -### 22.1 冲突关系 - -- 同路径 read/read 并发。 -- 同路径 read/write 串行。 -- 同路径 write/write 串行。 -- 不同路径 write/write 并发。 -- 递归目录访问与子文件正确冲突。 -- 相似前缀目录不会误判为父子目录。 -- 多 accesses Task 任意一项冲突时整体等待。 -- `all` 与任意非空 accesses 冲突。 -- `none` 不阻塞任何 Task。 - -### 22.2 队列公平性 - -- 后到的独立 Task 可以越过前面的 queued Task。 -- 后到的冲突 Task 不能越过前面的 queued Task。 -- writer 排队后,新 reader 不能继续越过 writer。 -- 一次 drain 可以启动多个互不冲突的 Task。 - -### 22.3 生命周期 - -- active Task resolve 后释放资源。 -- active Task reject 后释放资源。 -- `run()` 同步抛错时正确 reject 并推进队列。 -- queued Task 取消后不会启动。 -- active Task 取消后最终释放资源。 -- Task 不会重复 start、finish 或 settle。 - -### 22.4 结果顺序 - -- Task 可以按照 B、C、A 的顺序完成。 -- 最终 ToolResults 仍按照 A、B、C 返回。 -- synthetic result 和真实执行结果混合时,结果槽位仍与原始 Tool Call 一一对应。 -- 单个业务失败不会阻断其他 Task。 -- 基础设施级 rejection 在整批 settle 后升级为 Turn 级错误。 - -### 22.5 Admission - -- `exclusive_step` 不会被普通资源队列语义替代。 -- admission rejection 不执行 Tool 副作用。 -- 被拒绝调用仍产生与 Tool Call 配对的 synthetic result。 - -## 23. 演进计划 - -### 第一阶段:核心骨架 - -1. 引入 `ToolCallTask`、`ToolAccesses` 和 `ToolScheduler`。 -2. 保持现有 `exclusive_step` admission。 -3. Batch Runner 为 Tool Call 预分配固定 sequence。 -4. 使用 Scheduler 替换直接 `map(settleToolCall)` 启动方式。 -5. 使用有序 result slot 和 `Promise.allSettled()` 聚合。 -6. 优先覆盖 `Read`、`Write`、`Edit`、`Glob`、`Grep` 和 `apply_patch`。 - -### 第二阶段:逻辑资源 - -1. Todo、Goal、Plan 使用 Session/execution key。 -2. Terminal 使用 `(sessionId, ref)` key。 -3. Browser 使用 session/tab key。 -4. Computer Use 使用 device/window key。 -5. 为队列等待和阻塞原因增加观测指标。 - -### 第三阶段:外部系统与容量 - -1. Web provider 并发上限。 -2. MCP server/session/resource 策略。 -3. Agent spawn 并发上限。 -4. 评估跨 batch、跨 Agent 的共享 `ResourceCoordinator`。 - -## 24. 最终职责边界 - -```text -ToolTaskFactory - 决定“本次调用会访问什么资源” - -ToolScheduler - 决定“本次调用什么时候可以启动” - -ToolRuntime - 决定“本次调用如何可靠执行和持久化” - -ToolResultAssembler - 决定“结果以什么顺序交给模型” -``` - -## 25. 一句话定义 - -> 每个 Tool Call 被转换成一个携带完整 accesses 和固定 sequence 的 Task;新 Task 与任意 active Task 或前序 queued Task 冲突时排队,否则立即执行;整批 Task settle 后,Batch Runner 按原始 Tool Call 顺序组装 ToolResults。 - -## 26. 参考材料 - -- [`apache/maka#4487`](https://github.com/apache/maka/issues/4487) From 117232fde4c5c5dbfffababb91f17a6a41ab7e0e Mon Sep 17 00:00:00 2001 From: jarad-z <2280669499@qq.com> Date: Sat, 5 Sep 2026 01:29:22 +0800 Subject: [PATCH 6/6] fix(runtime): close authority admission races Make all() process-wide across participating authorities and capture filesystem identity only after admission. Pin exact reads to admitted targets and cover cross-batch ordering, create-read chains, and process composition. Generated-by: Codex --- ARCHITECTURE.md | 13 + ARCHITECTURE.zh-CN.md | 11 + .../filesystem-lease-composition.test.ts | 81 ++- .../src/server/execution-composition.ts | 3 + packages/runtime/package.json | 1 + .../src/__tests__/builtin-tool-access.test.ts | 40 +- .../domain-tool-authority-fallbacks.test.ts | 5 + .../src/__tests__/file-write-lock.test.ts | 18 + .../filesystem-admission-identity.test.ts | 503 ++++++++++++++++++ .../filesystem-authority-leases.test.ts | 84 +++ .../filesystem-mutation-outcome.test.ts | 30 +- .../__tests__/filesystem-stable-read.test.ts | 84 +++ .../filesystem-target-identity.test.ts | 8 +- ...lesystem-tool-call-batch-scenarios.test.ts | 89 +++- .../filesystem-worker-client.test.ts | 79 +-- .../process-resource-admission.test.ts | 331 ++++++++++++ .../src/__tests__/tool-call-batch.test.ts | 172 ++++++ .../tool-preparation-service.test.ts | 42 ++ packages/runtime/src/ai-sdk-backend.ts | 9 + packages/runtime/src/ai-sdk-turn.ts | 194 +++++-- packages/runtime/src/builtin-tools.ts | 10 +- packages/runtime/src/file-stable-read.ts | 89 ++++ packages/runtime/src/file-stable-write.ts | 2 +- packages/runtime/src/file-write-lock.ts | 11 +- packages/runtime/src/filesystem-executor.ts | 298 +++++++---- .../runtime/src/filesystem-worker/client.ts | 82 ++- .../src/filesystem-worker/operations.ts | 35 +- .../runtime/src/filesystem-worker/protocol.ts | 10 +- packages/runtime/src/preparation/claims.ts | 3 +- .../preparation/default-tool-authorities.ts | 15 +- .../preparation/placeholder-authorities.ts | 34 +- .../src/preparation/target-identity.ts | 24 +- .../preparation/tool-preparation-service.ts | 12 +- packages/runtime/src/preparation/types.ts | 2 +- .../runtime/src/process-resource-admission.ts | 331 ++++++++++++ packages/runtime/src/tool-call-batch.ts | 21 +- packages/runtime/src/workspace-executor.ts | 15 + 37 files changed, 2466 insertions(+), 325 deletions(-) create mode 100644 packages/runtime/src/__tests__/filesystem-admission-identity.test.ts create mode 100644 packages/runtime/src/__tests__/filesystem-stable-read.test.ts create mode 100644 packages/runtime/src/__tests__/process-resource-admission.test.ts create mode 100644 packages/runtime/src/file-stable-read.ts create mode 100644 packages/runtime/src/process-resource-admission.ts 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/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts b/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts index b293f6f53e..ec2f06c0b2 100644 --- a/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts +++ b/packages/runtime-host/src/__tests__/filesystem-lease-composition.test.ts @@ -24,8 +24,13 @@ 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 { buildBuiltinTools, type BuildBuiltinToolsOptions } from '@maka/runtime/builtin-tools'; +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'; @@ -111,3 +116,77 @@ test('root and child tool compositions share one filesystem coordinator', async 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 e8ce1f2d60..f3a77449bf 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -79,6 +79,7 @@ import { 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'; @@ -451,6 +452,7 @@ export async function createExecutionRuntimeHostComposition( } : {}), filesystemLeaseCoordinator: processFilesystemLeases, + processResourceAdmissionCoordinator: processResourceAdmissions, ...(sandboxManager ? { sandboxManager } : {}), ...(filesystemWorker ? { filesystemWorker } : {}), }; @@ -459,6 +461,7 @@ export async function createExecutionRuntimeHostComposition( // declarations such as the selected shell; it never creates authorities. const toolPreparationService = new ToolPreparationService( buildBuiltinToolComposition(builtinTools).authorityRegistry, + processResourceAdmissions, ); const webSearchService = createHostWebSearchService({ policy: runtimePolicyStores.operations, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b84728a9c4..74610e90a8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -11,6 +11,7 @@ "./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", diff --git a/packages/runtime/src/__tests__/builtin-tool-access.test.ts b/packages/runtime/src/__tests__/builtin-tool-access.test.ts index 4547074c39..301aa13476 100644 --- a/packages/runtime/src/__tests__/builtin-tool-access.test.ts +++ b/packages/runtime/src/__tests__/builtin-tool-access.test.ts @@ -23,6 +23,7 @@ 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'; @@ -30,10 +31,9 @@ describe('builtin tool resource claims', () => { let cwd: string; const tools = new Map(); let preparationService: ToolPreparationService; - // claim.key must equal the executor's lock key. On Windows realpath returns - // backslash paths, and normalising here would break the claim==lock key - // invariant, so the key is compared verbatim. - const expectedKey = (path: string) => resolve(cwd, path); + // 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-'))); @@ -48,7 +48,13 @@ describe('builtin tool resource claims', () => { 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' }, + { + 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), @@ -58,6 +64,7 @@ describe('builtin tool resource claims', () => { authority: 'filesystem:workspace', key: expectedKey('a.ts'), mode: 'write', + scope: 'exact', }, ], ); @@ -71,6 +78,7 @@ describe('builtin tool resource claims', () => { authority: 'filesystem:workspace', key: expectedKey(input.path), mode: 'write', + scope: 'exact', }, ]); } @@ -145,20 +153,34 @@ describe('builtin tool resource claims', () => { authority: 'filesystem:workspace', key: expectedKey('changed.txt'), mode: 'write', + scope: 'exact', }, ], ); - // A string (multi-operation) patch falls back to the plain impl. Until the - // parser is shared safely with prepare, it must conservatively claim all. + // 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\n+x\n*** End Patch', + [ + '*** Begin Patch', + '*** Add File: b.txt', + '+b', + '*** Add File: a.txt', + '+a', + '*** End Patch', + ].join('\n'), cwd, ), - [{ kind: 'all' }], + ['a.txt', 'b.txt'].map((path) => ({ + kind: 'keyed', + authority: 'filesystem:workspace', + key: expectedKey(path), + mode: 'write', + scope: 'exact', + })), ); }); diff --git a/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts b/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts index 0f89b021fa..44720e90af 100644 --- a/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts +++ b/packages/runtime/src/__tests__/domain-tool-authority-fallbacks.test.ts @@ -59,6 +59,11 @@ describe('domain tool authority fallbacks', () => { 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' }]); diff --git a/packages/runtime/src/__tests__/file-write-lock.test.ts b/packages/runtime/src/__tests__/file-write-lock.test.ts index fe80b3f9b5..9d6a0e0397 100644 --- a/packages/runtime/src/__tests__/file-write-lock.test.ts +++ b/packages/runtime/src/__tests__/file-write-lock.test.ts @@ -27,6 +27,7 @@ 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)); @@ -110,4 +111,21 @@ describe('withFileWriteLock', () => { 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-authority-leases.test.ts b/packages/runtime/src/__tests__/filesystem-authority-leases.test.ts index 107f7c8037..9237af5381 100644 --- a/packages/runtime/src/__tests__/filesystem-authority-leases.test.ts +++ b/packages/runtime/src/__tests__/filesystem-authority-leases.test.ts @@ -29,6 +29,7 @@ import type { FilesystemWorkerExecuteInput } from '../filesystem-worker/client.j 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(): { @@ -42,6 +43,89 @@ function deferred(): { 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 { diff --git a/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts b/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts index 114fd9b5ee..0153b8324a 100644 --- a/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts +++ b/packages/runtime/src/__tests__/filesystem-mutation-outcome.test.ts @@ -211,8 +211,8 @@ describe('filesystem mutation unknown-outcome classification', () => { }); }); -describe('filesystem mutation T0 identity capture (queue-window closure)', () => { - test('a queued mutation rejects a replacement before dispatching its effect', 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'); @@ -225,13 +225,13 @@ describe('filesystem mutation T0 identity capture (queue-window closure)', () => const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); - let calls = 0; + 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 }; } @@ -247,24 +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 prepared key, detects - // the identity drift inside the lease, and must not dispatch. + 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, - assert.rejects(second, { code: 'filesystem_prepared_target_changed' }), - ]); - assert.equal(calls, 1, 'the changed queued target must never reach the raw worker'); + await Promise.all([first, second]); + 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 index d589472f39..b93e94a1c5 100644 --- a/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts +++ b/packages/runtime/src/__tests__/filesystem-tool-call-batch-scenarios.test.ts @@ -29,7 +29,12 @@ 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'; @@ -40,6 +45,85 @@ afterEach(async () => { }); 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(); @@ -344,14 +428,16 @@ 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); + 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; @@ -391,6 +477,7 @@ function startBatch( }, }; }), + processAdmission ? { processAdmission } : {}, ); return { claims, outcomes }; 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__/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-call-batch.test.ts b/packages/runtime/src/__tests__/tool-call-batch.test.ts index 758226271c..333edb302f 100644 --- a/packages/runtime/src/__tests__/tool-call-batch.test.ts +++ b/packages/runtime/src/__tests__/tool-call-batch.test.ts @@ -21,6 +21,8 @@ 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[] => [ @@ -198,8 +200,178 @@ describe('settleToolCallBatch', () => { 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; diff --git a/packages/runtime/src/__tests__/tool-preparation-service.test.ts b/packages/runtime/src/__tests__/tool-preparation-service.test.ts index 4ab272353b..333723eb41 100644 --- a/packages/runtime/src/__tests__/tool-preparation-service.test.ts +++ b/packages/runtime/src/__tests__/tool-preparation-service.test.ts @@ -26,6 +26,7 @@ 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; @@ -143,6 +144,37 @@ describe('ToolPreparationService (single dispatch entry)', () => { 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 = { @@ -191,3 +223,13 @@ describe('ToolPreparationService (single dispatch entry)', () => { 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/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/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index ff7139e1b2..df83104098 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -71,6 +71,7 @@ import { 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 { @@ -196,6 +197,8 @@ export interface BuildBuiltinToolsOptions { 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: { @@ -228,6 +231,11 @@ function buildBuiltinToolDefinitions( ...(options.filesystemLeaseCoordinator ? { filesystemLeaseCoordinator: options.filesystemLeaseCoordinator } : {}), + ...(options.processResourceAdmissionCoordinator + ? { + processResourceAdmissionCoordinator: options.processResourceAdmissionCoordinator, + } + : {}), }); const filesystem = filesystemOwner.executor; const filesystemAuthority = includeAuthorities ? filesystemOwner.authority : undefined; @@ -876,7 +884,7 @@ export function buildBuiltinToolComposition( return { tools: tools.map(stripResourceAuthority), authorityRegistry: new ToolAuthorityRegistry(registrations).withRegistrations( - defaultToolAuthorityRegistrations(), + defaultToolAuthorityRegistrations(options.processResourceAdmissionCoordinator), ), }; } 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 a783dae58b..0c83e21734 100644 --- a/packages/runtime/src/file-write-lock.ts +++ b/packages/runtime/src/file-write-lock.ts @@ -24,6 +24,7 @@ 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` @@ -34,9 +35,11 @@ import { hostFilesystemLeaseKey } from './filesystem-lease-key.js'; * Windows case fold as the filesystem owner before entering the shared queue. */ export function withFileWriteLock(key: string, fn: () => Promise): Promise { - return processFilesystemLeases.withLease( - { key: hostFilesystemLeaseKey(key), mode: 'write', scope: 'exact' }, - undefined, - fn, + return processResourceAdmissions.withShared(undefined, () => + processFilesystemLeases.withLease( + { key: hostFilesystemLeaseKey(key), mode: 'write', scope: 'exact' }, + undefined, + fn, + ), ); } diff --git a/packages/runtime/src/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index da0a474c3b..528b71576e 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -51,8 +51,13 @@ import { 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'; @@ -79,6 +84,7 @@ import type { WorkspacePathScope, WorkspaceReadModifyWriteExecutor, WorkspaceSearchExecutor, + WorkspaceStableReadExecutor, WorkspaceWriteExecutor, } from './workspace-executor.js'; @@ -144,6 +150,7 @@ export type FilesystemWorkspaceExecutor = WorkspaceWriteExecutor & WorkspaceEditExecutor & Partial & Partial & + Partial & WorkspaceSearchExecutor; export interface BoundaryFilesystemExecutorInput { @@ -152,6 +159,10 @@ export interface BoundaryFilesystemExecutorInput { /** 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; } /** @@ -167,10 +178,10 @@ function pathScopeForBoundary(boundary: ExecutionBoundary | undefined): Workspac } /** - * Capture the target's stable identity at prepare/direct-call T0, before lease - * admission. This is the inode the owner rechecks inside the lease and the - * worker compare-and-swaps against. 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) @@ -206,7 +217,7 @@ async function captureFilesystemTargetIdentity( interface FilesystemBackend { run( call: FilesystemBackendExecuteInput, - expectedIdentity?: FilesystemTargetIdentity, + target: AdmittedTargetContract, ): Promise; resolveTarget(input: { cwd: string; @@ -239,19 +250,43 @@ function buildFilesystemBackend(input: BoundaryFilesystemExecutorInput): Filesys }; 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, @@ -265,16 +300,12 @@ function buildFilesystemBackend(input: BoundaryFilesystemExecutorInput): Filesys 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') { @@ -329,8 +360,9 @@ function buildFilesystemBackend(input: BoundaryFilesystemExecutorInput): Filesys inputArg.path, inputArg.semantics, ); - // Capture the target's stable identity at T0, before entering the lease - // queue. Execute re-resolves it only after admission on this prepared key. + // 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 @@ -359,8 +391,7 @@ function buildFilesystemBackend(input: BoundaryFilesystemExecutorInput): Filesys export type FilesystemAuthorityInput = FilesystemExecuteInput | FilesystemApplyPatchInput; /** - * The process-visible identity captured at prepare-time (T0), expressed in the - * authority's vocabulary. A create target with no on-disk inode is `missing`. + * The process-visible identity captured while the matching lease is held. */ function toTargetIdentity( identity: FilesystemTargetIdentity | undefined, @@ -377,6 +408,10 @@ function toExpectedIdentity(identity: TargetIdentity): FilesystemTargetIdentity 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; @@ -470,29 +505,31 @@ function replaceOperationPath( return { ...call, operation: { ...call.operation, path: canonicalPath } }; } -interface PreparedFilesystemAccess { +interface PreparedFilesystemClaim { readonly target: FilesystemAuthorityInput; readonly semantics: 'target' | 'entry'; - readonly resolved: ResolvedTarget; + readonly canonicalPath: string; readonly lease: FilesystemLeaseRequest; } -export class FilesystemPreparedTargetChangedError extends Error { - override readonly name = 'FilesystemPreparedTargetChangedError'; - readonly code = 'filesystem_prepared_target_changed'; +export class FilesystemPreparedClaimChangedError extends Error { + override readonly name = 'FilesystemPreparedClaimChangedError'; + readonly code = 'filesystem_prepared_claim_changed'; constructor() { - super('The approved filesystem target changed before execution.'); + super('The prepared filesystem claim changed before execution; prepare the operation again.'); } } -function assertSamePreparedTarget(prepared: ResolvedTarget, now: ResolvedTarget): void { +function assertSamePreparedClaim( + prepared: PreparedFilesystemClaim, + admitted: ResolvedTarget, +): void { if ( - prepared.canonicalPath !== now.canonicalPath || - prepared.leaseKey !== now.leaseKey || - identityChanged(prepared.identity, now.identity) + prepared.canonicalPath !== admitted.canonicalPath || + prepared.lease.key !== admitted.leaseKey ) { - throw new FilesystemPreparedTargetChangedError(); + throw new FilesystemPreparedClaimChangedError(); } } @@ -516,13 +553,13 @@ async function prepareFilesystemAccess( backend: FilesystemBackend, target: FilesystemAuthorityInput, context: Pick, -): Promise { +): Promise { const semantics = filesystemSemantics(target); const resolved = await backend.resolveTarget(resourceArgsFor(target, context)); return { target, semantics, - resolved, + canonicalPath: resolved.canonicalPath, lease: filesystemLeaseFor(target, resolved.leaseKey), }; } @@ -554,35 +591,40 @@ export function createFilesystemResourceOwner( ): FilesystemResourceOwner { const backend = buildFilesystemBackend(input); const coordinator = input.filesystemLeaseCoordinator ?? processFilesystemLeases; + const processAdmission = input.processResourceAdmissionCoordinator ?? processResourceAdmissions; const executeAccess = async ( - access: PreparedFilesystemAccess, + access: PreparedFilesystemClaim, context: Pick, signal?: AbortSignal, ): Promise => { const abortSignal = signal ?? context.abortSignal; - return await coordinator.withLease(access.lease, abortSignal, async () => { - const now = await backend.resolveTarget(resourceArgsFor(access.target, context, signal)); - assertSamePreparedTarget(access.resolved, now); - const call = replaceOperationPath( - toBackendCall(access.target, context, signal), - now.canonicalPath, - ); - try { - return await backend.run( - call, - access.lease.mode === 'write' ? toExpectedIdentity(now.identity) : undefined, + 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, ); - } catch (error) { - throw access.lease.mode === 'write' ? settleMutationFailure(error) : error; - } - }); + 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 => + ): Promise => await Promise.all( operations.map((operation) => prepareFilesystemAccess(backend, { operation, ...context }, context), @@ -590,62 +632,70 @@ export function createFilesystemResourceOwner( ); const executePatchBatch = async ( - accesses: readonly PreparedFilesystemAccess[], + accesses: readonly PreparedFilesystemClaim[], context: Pick, signal?: AbortSignal, ): Promise => { const requests = normalizeFilesystemLeaseRequests(accesses.map((access) => access.lease)); const abortSignal = signal ?? context.abortSignal; - return await coordinator.withLeases(requests, abortSignal, async () => { - const preflight: ResolvedTarget[] = []; - for (let index = 0; index < accesses.length; index += 1) { - const access = accesses[index]!; - try { - const now = await backend.resolveTarget(resourceArgsFor(access.target, context, signal)); - assertSamePreparedTarget(access.resolved, now); - preflight.push(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 seenLeaseKeys = new Set(); - const operations = accesses.map( - (access) => (access.target as FilesystemApplyPatchInput).operation, - ); - return await executeApplyPatchOperations( - operations, - async (operation) => { - const index = operationIndex++; + 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]!; - let now = preflight[index]!; - if (seenLeaseKeys.has(access.lease.key)) { - now = await backend.resolveTarget(resourceArgsFor(access.target, context, signal)); - } - seenLeaseKeys.add(access.lease.key); - const call = replaceOperationPath( - toBackendCall({ operation, ...context }, context, signal), - now.canonicalPath, - ); try { - const result = await backend.run(call, toExpectedIdentity(now.identity)); - if (result.kind !== 'apply_patch') { - throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`); - } + const now = await backend.resolveTarget( + resourceArgsFor(access.target, context, signal), + ); + assertSamePreparedClaim(access, now); } catch (error) { - throw settleMutationFailure(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)}`, + }; } - }, - abortSignal, - ); - }); + } + + 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 = { @@ -715,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( @@ -730,7 +780,7 @@ interface WorkspaceFilesystemBackend { execute( input: FilesystemBackendExecuteInput, scope: WorkspacePathScope, - expectedIdentity?: FilesystemTargetIdentity, + target: AdmittedTargetContract, ): Promise; } @@ -743,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, @@ -771,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 = @@ -840,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 }; @@ -850,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 }, ); @@ -872,7 +948,7 @@ function createWorkspaceFilesystemExecutor( path, label: 'Edit', scope, - approvedIdentity: expectedIdentity, + approvedIdentity: toExpectedIdentity(target.identity), transform: (ctx) => { originalContent = ctx.content ?? ''; edited = computeEditedSource( @@ -935,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-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 index 6787f7f9a7..d95805b604 100644 --- a/packages/runtime/src/preparation/claims.ts +++ b/packages/runtime/src/preparation/claims.ts @@ -37,7 +37,8 @@ export function claimsConflict( } export function resourceClaimsConflict(a: ResourceClaim, b: ResourceClaim): boolean { - // Placeholder `all()` is the fail-closed global serialization marker. + // 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. diff --git a/packages/runtime/src/preparation/default-tool-authorities.ts b/packages/runtime/src/preparation/default-tool-authorities.ts index eebbdcf1db..3fc3c05c51 100644 --- a/packages/runtime/src/preparation/default-tool-authorities.ts +++ b/packages/runtime/src/preparation/default-tool-authorities.ts @@ -18,12 +18,19 @@ */ 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', @@ -71,9 +78,13 @@ export const EXPLICIT_ALL_TOOL_AUTHORITY_IDS = Object.freeze([ * Static policy registrations are composed once into the process registry. * Dynamic and newly introduced tools remain safe through registry-miss all(). */ -export function defaultToolAuthorityRegistrations(): readonly ToolAuthorityRegistration[] { +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()] as const), + ...EXPLICIT_ALL_TOOL_AUTHORITY_IDS.map( + (toolId) => [toolId, allResourceAuthority(processAdmission)] as const, + ), ]); } diff --git a/packages/runtime/src/preparation/placeholder-authorities.ts b/packages/runtime/src/preparation/placeholder-authorities.ts index a4812d550a..2898fb3cbd 100644 --- a/packages/runtime/src/preparation/placeholder-authorities.ts +++ b/packages/runtime/src/preparation/placeholder-authorities.ts @@ -23,10 +23,14 @@ // - `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'}] -> conflicts with everything -> global -// serialization against non-empty claims, fail-closed. It is the conservative -// default for real effects whose precise authority is not registered yet. +// - `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'; @@ -41,17 +45,31 @@ export const noneResourceAuthority = (): ResourceAuthority => }, }); -export const allResourceAuthority = (): ResourceAuthority => ({ +export const allResourceAuthority = ( + admission: ProcessResourceAdmissionCoordinator = processResourceAdmissions, +): ResourceAuthority => ({ async prepare(_input, context: AuthorityContext): Promise> { const { effect } = context; - return oneShotOperation({ - claims: [{ kind: 'all' }], - execute: (signal, fallbackEffect) => + 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, diff --git a/packages/runtime/src/preparation/target-identity.ts b/packages/runtime/src/preparation/target-identity.ts index 4830986015..5e6c824ac3 100644 --- a/packages/runtime/src/preparation/target-identity.ts +++ b/packages/runtime/src/preparation/target-identity.ts @@ -18,12 +18,10 @@ */ // packages/runtime/src/preparation/target-identity.ts -// The `(dev, ino)` snapshot contract shared between the filesystem Authority's -// `prepare` (capture at T0) and `PreparedOperation.execute` (re-check at run -// time). This is the coarse pre-guard against "the target was replaced while the -// call waited for the lock". It is NOT a replacement for the fd-pinned -// read-modify-write in file-stable-write.ts, which catches in-place content -// changes that leave the inode unchanged. +// 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 } @@ -38,14 +36,22 @@ export interface ResolvedTarget { 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 the identity captured at prepare-time no longer matches the state - * observed at execute-time. `missing` is only stable when both sides are - * `missing` (a create target that is still absent). + * 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; diff --git a/packages/runtime/src/preparation/tool-preparation-service.ts b/packages/runtime/src/preparation/tool-preparation-service.ts index 34370c6874..8275f5fff5 100644 --- a/packages/runtime/src/preparation/tool-preparation-service.ts +++ b/packages/runtime/src/preparation/tool-preparation-service.ts @@ -30,6 +30,10 @@ 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'; @@ -66,7 +70,10 @@ interface SchemaValidator { } export class ToolPreparationService { - constructor(private readonly authorities: ToolAuthorityRegistry) {} + constructor( + private readonly authorities: ToolAuthorityRegistry, + readonly processAdmission: ProcessResourceAdmissionCoordinator = processResourceAdmissions, + ) {} async prepare(toolCall: { readonly tool: MakaTool; @@ -93,7 +100,8 @@ export class ToolPreparationService { // ③ 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(); + const authority = + this.authorities.resolve(canonical.toolId) ?? allResourceAuthority(this.processAdmission); const operation = await authority.prepare( canonical.input, this.toAuthorityContext(canonical, toolCall.ctx, toolCall.tool), diff --git a/packages/runtime/src/preparation/types.ts b/packages/runtime/src/preparation/types.ts index 36e23b69db..0750d665a7 100644 --- a/packages/runtime/src/preparation/types.ts +++ b/packages/runtime/src/preparation/types.ts @@ -78,7 +78,7 @@ export interface CoarseResourceClaim { readonly key: string; } -/** Placeholder `all()`: conflicts with everything. Fail-closed fallback only. */ +/** Scheduler description for process-exclusive all(); explicit none() has no claims. */ export interface AllResourceClaim { readonly kind: 'all'; } 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-call-batch.ts b/packages/runtime/src/tool-call-batch.ts index 1a9fdee3a1..4afd1c3933 100644 --- a/packages/runtime/src/tool-call-batch.ts +++ b/packages/runtime/src/tool-call-batch.ts @@ -18,6 +18,11 @@ */ 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 { @@ -37,6 +42,10 @@ export interface ToolCallBatchEntry { 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. @@ -45,7 +54,9 @@ export interface ToolCallBatchEntry { */ 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( @@ -61,16 +72,16 @@ export async function settleToolCallBatch( ); const resultSlots = prepared.map(({ slot, operation }) => { - const runnable: PreparedOperation = operation ?? { - claims: [{ kind: 'all' }], - execute: () => slot.entry.run(undefined) as Promise, - }; + 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: (candidate) => slot.entry.run(candidate === runnable ? operation : undefined), + 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/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 71c9df4f6c..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; @@ -297,6 +307,7 @@ export interface WorkspaceExecutor WorkspaceGlobExecutor, WorkspaceGrepExecutor, Partial, + Partial, Partial {} export class LocalWorkspaceExecutor implements WorkspaceExecutor { @@ -338,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 {