diff --git a/docs/子智能体文件租约并发修复-2026-10-18.md b/docs/子智能体文件租约并发修复-2026-10-18.md new file mode 100644 index 0000000..40e84d7 --- /dev/null +++ b/docs/子智能体文件租约并发修复-2026-10-18.md @@ -0,0 +1,55 @@ +# 子智能体文件租约:并发工具调用修复(2026-10-18) + +## 用户可见问题 + +- 同一回合里模型并发发出多条命令时,其中一条会返回「相关文件正由其他任务修改,请等待子任务或当前文件操作结束后再执行。」,但当时没有任何别的任务在改文件,命令本身也大多是只读的(`dir`/`findstr`)。 +- 实测证据:用户会话 `39965c03-076b-4267-93dd-3eb45ba1386d`,原生转录 `%APPDATA%\YCode\omp-sessions\session-39965c03-076b-4267-93dd-3eb45ba1386d.jsonl`。 + - `09:00:47.937` `findstr /s /n …` 启动,`09:00:47.938` `dir /b src\renderer` 启动(相隔 1 ms),后者被判冲突。 + - `09:00:55.663` `dir /b /s … | findstr …` 与 `09:00:55.664` `powershell …` 同样只有一条成功,而两次里"胜者"相反 → 竞态而非固定顺序。 + - 该回合没有任何 `spawn_agent`,所以与子智能体无关,是主模型自己在一轮里并行发了多条命令。 + +## 原因 + +- `src/main/agent/omp-turn.ts:149` 对每个 `host_tool_call` 起 fire-and-forget 协程,同一 assistant 消息里的多个宿主工具调用会**真正并发**执行;备用 HTTP 循环 `src/main/agent/loop.ts:144-162` 是串行的。 +- 改动前的 `src/main/subagents/scheduler.ts`(HEAD `c7d6c55`)把非只读工具的租约路径取成 `'.'`(工作区根),并把它当独占锁;只要有任意租约与之重叠就返回错误。于是两种情况都会误判:并发兄弟调用互撞;子智能体持有文件租约时主智能体的所有命令被拒(子智能体可存活至 10 分钟超时)。 + +## 改动(`src/main/subagents/scheduler.ts`,`scripts/subagent-regression.ts`) + +1. `leases: Map` 保持为**子智能体声明文件**的独占租约(语义与改动前一致);新增 `rootIntents: Map`(带计数)记录主智能体正在写入的具体文件。 +2. 主智能体侧新增按足迹的等待队列(`rootLeases` / `rootWaiters` / `acquireRootLease`):显式写工具(`write_file`/`edit_file`)取自身文件路径;其余工具(命令、连接器等)足迹未知,取工作区根。 +3. 冲突语义: + - root ↔ root:**先到先得排队**,不再报错。 + - root 显式写 ↔ 子智能体声明文件:仍**拒绝**,文案改为可执行的「该文件正由子智能体修改,请先 wait_agent 或改用其他文件:」。 + - 子智能体声明文件 ↔ 主智能体正在写该文件(写工具请求时即登记 `rootIntents`,含审批等待期):`spawn_agent` 被拒绝「该文件正在被主智能体写入,请等待该工具结束后再分配:」。父子两个方向都互斥。 + - 未知足迹工具 ↔ 子智能体租约:不再互斥(子智能体运行期间主智能体仍可执行命令)。这是有意取舍,见限制。 + - 子智能体 ↔ 子智能体:保持原样,`spawn_agent` 阶段即拒绝。 +4. 等待有上限(10 分钟,超时按可重试错误返回并带路径),取消或超时出队后会立即推进队列,避免泄漏的占用把后续无冲突工具一起挂住。 + +## 独立评审与处置 + +改动完成后由独立子代理(只读)审阅,发现并已修掉两处: + +- **P1(已修)**:第一版把主智能体租约只放进 `rootLeases`,导致「父先写、子后到」方向不再互斥(HEAD 原本双向互斥),同文件可能出现两个写入者。现在显式写工具在请求时登记 `rootIntents`,`acquire` 一并检查。 +- **P2(已修)**:等待者被取消或超时出队后没有推进队列,队首之后的**不冲突**等待者会被无谓阻塞。现在 `onAbort`/超时后都补一次 `pumpRootWaiters()`。 +- **P3(未修,已记录)**:`close()`/`cancelAll()` 不清理模块级 `rootWaiters`/`rootLeases`。现调用顺序(先 `abort` 再 `close`,见 `chat-turn-service.ts:545-547`)不会残留;若将来出现先 close 后 abort 的调用,排队者会存活到 10 分钟超时或回合结束后被授予。修复需要给 root 租约加 owner/run 标识。 + +方向参照(只读调研,非强制对齐):opencode 对同一文件的 `edit`/`patch` 采用按路径排队(`packages/core/src/file-mutation.ts:59-78`),不做拒绝式报错;本次与其方向一致,但保留了 YCode 自己的原子写与写前重读校验。 + +## 验证 + +| 验证 | 结果 | +| --- | --- | +| `npm run test:subagents` | 40 项通过(新增 9 项:未知足迹兄弟调用串行不报错、排队者自动接续、并发三条都成功且同一时刻只有一条在跑、父写期间子智能体不能声明同一文件、父写正常完成、队首冲突时无关等待者排队、取消队首后无关等待者立刻接续、占用期间到来的调用排队、排队调用随取消以 AbortError 结束) | +| `npm run typecheck` | 通过 | +| `npm run test:subagents:app` | 28 项通过(真实 Electron + 已安装 OMP + 回环模型,0 renderer 错误);报告 `.tmp/subagent-app-UpT2kI/report.json` | +| 旧场景直接复现 | `.tmp/lease-repro/repro.ts`:同一回合并发 3 条未知足迹工具 + 1 次读取,修复前 2 条 BLOCKED,修复后 4 条全部 OK,命令串行(1207 ms ≈ 3 × 400 ms) | +| `npm run build` | 通过(含 sandbox 原生构建);`out/main/index.mjs` 已确认不再包含旧文案 | + +## 限制与未验证 + +- **未重启用户窗口**:用户运行中的是 16:56 启动的安装版;安装目录 `resources/app.asar` 仍是更早的构建,需要重新分发并重启才生效。 +- 未知足迹工具(shell、连接器)与「子智能体正在写的文件」之间不再互斥:主智能体在子智能体运行期间执行会改文件的命令(格式化、`git checkout` 等)仍可能覆盖子智能体结果,靠 `wait_agent` 纪律约束;本次未做命令足迹静态分析。 +- `close()` 对卡死 worker 的等待仍无上限(不在本次改动范围):若某运行时不响应取消,子智能体租约仍可能在进程内残留;等待队列有超时兜底,但租约本身不会自动清除。 +- 快速路径(无需排队时)不再二次检查 `signal.aborted`:取消恰好发生在进入执行之间时,工具会执行一次、结果被丢弃,与既有 `execute()` 的行为一致。 +- 未做原版对照:WorkBuddy 没有子智能体文件租约概念,本项不产生 1:1 结论。 +- 未重跑全部历史专项(沙盒等模块当时有并行修改),本轮只跑与本次改动直接相关的三组。 diff --git a/docs/接手状态-2026-10-15.md b/docs/接手状态-2026-10-15.md index c7cd590..a4e6fe2 100644 --- a/docs/接手状态-2026-10-15.md +++ b/docs/接手状态-2026-10-15.md @@ -1,5 +1,7 @@ # YCode 接手状态(2026-10-15) +> 2026-10-18 子智能体文件租约并发修复:[实现、证据与限制](子智能体文件租约并发修复-2026-10-18.md)。修复同一 assistant 回合内并发宿主工具调用被误判为「相关文件正由其他任务修改」的问题:主智能体侧新增按足迹的先到先得等待队列(显式写工具取文件路径,命令等未知足迹取工作区根),root↔root 排队而非报错,等待有 10 分钟上限并随取消/超时推进队列;root 显式写↔子智能体声明文件、子智能体声明文件↔主智能体正在写的文件双向仍拒绝并给出可执行文案,子智能体↔子智能体保持 spawn 拒绝。用户会话 `39965c03` 原生转录显示两条命令相隔 1 ms 同时启动、后者被判冲突,属竞态。独立子代理评审发现并修掉父→子方向互斥缺失(P1)与出队后不推进队列(P2);`close()` 不清理 root 队列(P3)已记录未修。`npm run test:subagents` 40 项、`npm run test:subagents:app` 28 项(真实 Electron/OMP,0 renderer 错误)、typecheck、完整 build 通过。未重启用户安装版;未知足迹工具与子智能体租约不再互斥为有意取舍;`close()` 对卡死 worker 的等待仍无上限。 + > 2026-10-18 v0.01 Windows测试分发包:[构建与限制](分发包-v0.01-2026-10-18.md)。用户指定文件名版本v0.01,内部SemVer改0.0.1;NSIS按用户安装、可选目录、保留卸载数据,产物`release/YCode-v0.01-Setup-x64.exe`(140.3MiB),附说明和SHA256SUMS。完整dist:installer及实际包内容/正式exe隔离启动10项通过,0未捕获renderer异常,已查看截图;报告`.tmp/release-v001-M4MP5b/report.json`。未签名、未测干净设备安装卸载/GPU全路径;OMP仍外部探测,个人配置/专家不随包供应。保留其他工作区修改,未提交、未外部发布。 > 2026-10-18 沙盒 Git 一次性对齐:[完整实现与限制](沙盒Git完整接入-2026-10-18.md)、[功能验收矩阵](沙盒Git接入验收矩阵-2026-10-18.md)。组件升级至 0.7.0 / protocol 1:内置 82 文件 MinGit,接入宿主普通仓库/linked worktree 历史和索引审批导入、本地常用 Git、项目+`.git` 原生组事务写回/恢复、HTTPS/SSH、DPAPI scoped 凭证、clone/fetch/pull/push/reconcile、维护 CLI 与生产 IPC。覆盖拒绝、并发、真实主进程死亡恢复、unknown push、TLS/host-key 校验、取消/策略撤销;本地29/29、安全25/25、远程40/40、生产 Electron/OMP 52/52、最终包内 Git20/20、旧兼容专项314项重新回归;`npm run build` 通过,总计480项。构建验证摘要见 `.tmp/git-complete-validation.json`,代码/日志保留在本工作区,未提交、未改用户真实仓库/远程或生产沙盒状态、未重启用户窗口。明确限制仍有标准用户/安装发布/独立安全审查、Git 大仓库/部分高级特性和原版 Git 运行时 1:1 证据。 diff --git a/scripts/subagent-regression.ts b/scripts/subagent-regression.ts index e9c3a7c..c267c54 100644 --- a/scripts/subagent-regression.ts +++ b/scripts/subagent-regression.ts @@ -94,8 +94,16 @@ function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: strin && !(await executeTurnTool('spawn_agent', { name: 'nested', task: 'nested' }, workerCtx, policy)).ok) check('writer cannot modify another file', !(await executeTurnTool('write_file', { path: 'outside.txt', content: 'no' }, workerCtx, policy)).ok) check('same-file sibling assignment is denied', !(await state.call('spawn_agent', { name: '冲突', task: 'x', access: 'write', files: ['owned.txt'] })).ok) - check('parent write and shell cannot collide with a child lease', !(await state.call('write_file', { path: 'owned.txt', content: 'parent' })).ok - && !(await state.call('execute_command', { command: 'echo no' })).ok) + const blockedWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent' }) + check('parent write to a child-leased file is still denied', !blockedWrite.ok) + check('the denial names the file and the next step instead of blaming another task', blockedWrite.content.includes('子智能体') && blockedWrite.content.includes('owned.txt')) + // 同一回合里并发发出的命令必须都能跑完:以前后到的一条会被判成"文件正由其他任务修改"。 + const [slow, sibling] = await Promise.all([ + state.call('execute_command', { command: 'ping -n 2 127.0.0.1 >nul && echo first' }), + state.call('execute_command', { command: 'echo second' }) + ]) + check('same-round shell calls both succeed while a child holds a file lease', slow.ok && sibling.ok + && slow.content.includes('first') && sibling.content.includes('second')) const childWrite = await executeTurnTool('write_file', { path: 'owned.txt', content: 'child' }, workerCtx, policy) check('declared full-access child file write really reaches disk', childWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'child') state.release.get(writer.id)!() @@ -113,6 +121,86 @@ function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: strin check('rewind includes child writes before later parent writes', changes.length === 1 && changes[0].before === null && changes[0].after === 'parent-after') await state.scheduler.close() } +{ + // 同一回合里并发发出的未知足迹工具(命令、连接器等)必须排队串行,而不是互相判成文件冲突。 + const id = 'root-queue-' + Math.random().toString(36).slice(2) + const controller = new AbortController() + const started: string[] = [], finished: string[] = [] + const gates = new Map void>() + let active = 0, peak = 0 + const tool = (name: string) => ({ type: 'function', function: { name, description: '排队用例的假工具', parameters: { type: 'object', properties: {} } } }) + const capabilities: CapabilityToolSnapshot = { + specs: [tool('custom_command'), tool('write_file')], + async execute(_name, args) { + const key = String((args as { key?: unknown; path?: unknown }).key ?? (args as { path?: unknown }).path ?? '') + started.push(key); active++; peak = Math.max(peak, active) + await new Promise(done => gates.set(key, done)) + active--; finished.push(key) + return { ok: true, content: 'done:' + key } + } + } + const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'queue-model', enabled: true, + signal: controller.signal, emit: () => {}, capabilities, + worker: async () => { throw new Error('排队用例不创建子智能体运行时') } }) + const ctx: ConversationToolContext = { runId: id, toolCallId: 'queue', sessionId: 'parent-' + id, workspaceRoot: root, + permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: () => {} } + const run = (key: string) => executeTurnTool('custom_command', { key }, ctx, scheduler.tools) + const write = (path: string, context = ctx) => executeTurnTool('write_file', { path }, context, scheduler.tools) + const startedKey = async (count: number): Promise => { + for (let i = 0; i < 200 && started.length < count; i++) await pause(5) + return started[count - 1] + } + const waitForKey = async (key: string): Promise => { + for (let i = 0; i < 200 && !started.includes(key); i++) await pause(5) + } + const calls = [run('a'), run('b'), run('c')] + const first = await startedKey(1) + check('sibling root tools with unknown footprints are serialized, not rejected', started.length === 1 && peak === 1) + gates.get(first)!() + const second = await startedKey(2) + check('the next sibling starts by itself once the holder finishes', started.length === 2 && peak === 1) + gates.get(second)!() + gates.get(await startedKey(3))!() + const results = await Promise.all(calls) + check('every concurrently issued root call succeeds exactly once in first-come order', results.every(result => result.ok) + && new Set(results.map(result => result.content)).size === 3 && finished.length === 3 && peak === 1) + // 父写文件期间到达的 spawn_agent 不能声明同一文件:父→子方向也必须互斥。 + const pendingWrite = write('parent.txt') + await waitForKey('parent.txt') + const late = await executeTurnTool('spawn_agent', { name: 'late', task: 'same file', access: 'write', files: ['parent.txt'] }, ctx, scheduler.tools) + check('a child cannot declare the file the parent is writing right now', !late.ok && late.content.includes('主智能体')) + gates.get('parent.txt')!() + check('the pending parent write completes normally', (await pendingWrite).ok) + // 队首被取消后,后面与持有者不冲突的等待者必须立刻接续。 + const blocker = write('block.txt') + await waitForKey('block.txt') + const headController = new AbortController() + const head = write('block.txt', { ...ctx, signal: headController.signal }) + await pause(50) + const tail = write('queue-only.txt') + await pause(30) + check('a conflicting waiter holds the queue and an unrelated one waits behind it', !started.includes('queue-only.txt')) + headController.abort() + const headRejection = await head.then(() => undefined, (error: Error) => error) + await waitForKey('queue-only.txt') + check('cancelling the head lets the next non-conflicting waiter start at once', + headRejection?.name === 'AbortError' && started.includes('queue-only.txt')) + gates.get('queue-only.txt')!() + await tail + gates.get('block.txt')!() + await blocker + const holder = run('hold') + await waitForKey('hold') + const queued = run('waiting') + await pause(30) + check('a sibling arriving while the footprint is held waits instead of failing', !started.includes('waiting')) + controller.abort() + const rejection = await queued.then(() => undefined, (error: Error) => error) + check('a queued root call is cancelled instead of hanging when the turn aborts', rejection?.name === 'AbortError') + gates.get('hold')!() + await holder + await scheduler.close() +} { const state = setup() const reader = await state.spawn('只读') diff --git a/src/main/subagents/scheduler.ts b/src/main/subagents/scheduler.ts index dad23d6..b3c2e9d 100644 --- a/src/main/subagents/scheduler.ts +++ b/src/main/subagents/scheduler.ts @@ -38,13 +38,87 @@ interface Entry { turns: number } const schedulers = new Map() +/** + * 子智能体按声明文件持有的独占租约;主智能体工具调用按真实足迹排队, + * 不再把同一回合里并发发出的命令当成"别的任务正在修改同一个文件"。 + */ const leases = new Map() +/** 主智能体正在写入的具体文件(写工具请求时即在册),让并发的 spawn_agent 也能看到父子同文件冲突。 */ +const rootIntents = new Map() +/** 主智能体侧的活动足迹:显式文件路径,或未知足迹工具(命令、连接器等)的工作区根。 */ +const rootLeases = new Set() +interface RootWaiter { + path: string + signal?: AbortSignal + grant(release: () => void): void + fail(error: Error): void + onAbort?(): void + timer?: ReturnType +} +const rootWaiters: RootWaiter[] = [] +const ROOT_LEASE_WAIT_TIMEOUT_MS = 10 * 60_000 let activeWorkers = 0 const MAX_ACTIVE = 5 const WRITE_TOOLS = new Set(['write_file', 'edit_file']) const READ_TOOLS = new Set(['list_dir', 'read_file', 'ycode_load_skill', 'ycode_read_skill_resource', 'ycode_expert_resource']) const normalize = (path: string): string => process.platform === 'win32' ? path.toLowerCase() : path const overlaps = (left: string, right: string): boolean => left === right || left.startsWith(right + sep) || right.startsWith(left + sep) +const addRootIntent = (path: string): void => { rootIntents.set(path, (rootIntents.get(path) ?? 0) + 1) } +const removeRootIntent = (path: string): void => { + const count = (rootIntents.get(path) ?? 0) - 1 + if (count > 0) rootIntents.set(path, count) + else rootIntents.delete(path) +} +function releaseRootLease(path: string): void { + rootLeases.delete(path) + pumpRootWaiters() +} +/** 先到先得:队首不再冲突才授予足迹,同一批并发调用因此得到确定的串行顺序。 */ +function pumpRootWaiters(): void { + while (rootWaiters.length) { + const next = rootWaiters[0] + if ([...rootLeases].some(held => overlaps(held, next.path))) return + rootWaiters.shift() + rootLeases.add(next.path) + if (next.timer) clearTimeout(next.timer) + if (next.signal && next.onAbort) next.signal.removeEventListener('abort', next.onAbort) + next.grant(() => releaseRootLease(next.path)) + } +} +function acquireRootLease(path: string, signal?: AbortSignal): Promise<() => void> { + if (!rootWaiters.length && ![...rootLeases].some(held => overlaps(held, path))) { + rootLeases.add(path) + return Promise.resolve(() => releaseRootLease(path)) + } + return new Promise<() => void>((grant, fail) => { + const waiter: RootWaiter = { path, signal, grant, fail } + const remove = (): number => { + const index = rootWaiters.indexOf(waiter) + if (index >= 0) rootWaiters.splice(index, 1) + if (waiter.timer) clearTimeout(waiter.timer) + return index + } + waiter.onAbort = () => { + if (remove() < 0) return + fail(Object.assign(new Error('回合已取消'), { name: 'AbortError' })) + pumpRootWaiters() + } + // 泄漏的占用不能把后续工具永久挂住:等待有上限,超时按可重试错误返回。 + waiter.timer = setTimeout(() => { + if (remove() < 0) return + if (signal && waiter.onAbort) signal.removeEventListener('abort', waiter.onAbort) + fail(new Error('等待其他工具释放文件占用超时,请稍后重试:' + path)) + pumpRootWaiters() + }, ROOT_LEASE_WAIT_TIMEOUT_MS) + waiter.timer.unref?.() + rootWaiters.push(waiter) + if (signal) { + if (signal.aborted) { waiter.onAbort(); return } + signal.addEventListener('abort', waiter.onAbort, { once: true }) + } + pumpRootWaiters() + }) +} async function resolveWorkspacePath(rootInput: string, input: string): Promise { const root = await realpath(rootInput) const target = resolve(root, input.trim() || '.') @@ -91,7 +165,7 @@ export class SubagentScheduler { return [...scheduler.options.capabilities.specs.filter(tool => !SUBAGENT_TOOL_NAMES.has(tool.function.name)), ...controls] }, metadata: name => SUBAGENT_TOOL_NAMES.has(name) ? { capability: 'subagent' } : scheduler.options.capabilities.metadata?.(name), - aroundExecute: (name, args, _ctx, execute) => scheduler.rootExecute(name, args, execute), + aroundExecute: (name, args, ctx, execute) => scheduler.rootExecute(name, args, ctx, execute), async execute(name, args, ctx) { if (SUBAGENT_TOOL_NAMES.has(name)) return scheduler.execute(name, args, ctx) return scheduler.options.capabilities.execute(name, args, ctx) @@ -123,8 +197,13 @@ export class SubagentScheduler { if (!this.enabled) throw new Error('智能体团队管理已禁用,请由主智能体继续处理') } private acquire(entry: Entry): void { - for (const path of entry.paths) for (const [owned, owner] of leases) { - if (owner !== entry.view.id && overlaps(owned, path)) throw new Error('文件已分配给另一个子智能体,请等待完成后再分配:' + path) + for (const path of entry.paths) { + for (const [owned, owner] of leases) { + if (owner !== entry.view.id && overlaps(owned, path)) throw new Error('文件已分配给另一个子智能体,请等待完成后再分配:' + path) + } + for (const owned of rootIntents.keys()) { + if (overlaps(owned, path)) throw new Error('该文件正在被主智能体写入,请等待该工具结束后再分配:' + path) + } } for (const path of entry.paths) leases.set(path, entry.view.id) } @@ -147,16 +226,31 @@ export class SubagentScheduler { } return { files: [...new Set(files)], paths: [...new Set(paths)] } } - private async rootExecute(name: string, args: Record, execute: () => Promise): Promise { + /** + * 主智能体工具入口。只有声明目标文件的工具才知道真实足迹,其余工具(命令、连接器等)按工作区整体排队。 + * 同一回合里并发发出的兄弟调用会依次执行,而不是被判成"文件正由其他任务修改"。 + */ + private async rootExecute(name: string, args: Record, ctx: ConversationToolContext, execute: () => Promise): Promise { if (SUBAGENT_TOOL_NAMES.has(name) || READ_TOOLS.has(name) || ['browser', 'todo_write', 'create_plan', 'ask_user_question', 'ask_followup_question'].includes(name)) return execute() + const explicit = WRITE_TOOLS.has(name) let path: string - try { path = normalize(await resolveWorkspacePath(this.options.workspaceRoot, WRITE_TOOLS.has(name) ? String(args.path ?? '') : '.')) } + try { path = normalize(await resolveWorkspacePath(this.options.workspaceRoot, explicit ? String(args.path ?? '') : '.')) } catch (error) { return { ok: false, content: error instanceof Error ? error.message : String(error) } } - if ([...leases.keys()].some(owned => overlaps(owned, path))) return { ok: false, content: '相关文件正由其他任务修改,请等待子任务或当前文件操作结束后再执行。' } - const owner = randomUUID() - leases.set(path, owner) + // 只有声明了具体文件的调用才能判定真冲突:子智能体正在改这个文件就拒绝,并给出可执行的下一步。 + if (explicit) { + const held = [...leases.keys()].find(owned => overlaps(owned, path)) + if (held) return { ok: false, content: '该文件正由子智能体修改,请先 wait_agent 或改用其他文件:' + path } + addRootIntent(path) + } + let release: () => void + try { release = await acquireRootLease(path, ctx.signal) } + catch (error) { + if (explicit) removeRootIntent(path) + if ((error as Error).name === 'AbortError') throw error + return { ok: false, content: error instanceof Error ? error.message : String(error) } + } try { return await execute() } - finally { if (leases.get(path) === owner) leases.delete(path) } + finally { release(); if (explicit) removeRootIntent(path) } } private workerTools(entry: Entry): CapabilityToolSnapshot { const allowed = (name: string): boolean => READ_TOOLS.has(name) || entry.view.access === 'write' && WRITE_TOOLS.has(name)