From 97162cc87cc01f8848fac23095423e8afbdb61dd Mon Sep 17 00:00:00 2001 From: chimney Date: Wed, 21 Oct 2026 03:19:59 +0800 Subject: [PATCH 01/50] fix(file): reserve audit-store components in absolute paths (#329) --- docs/CHANGELOG.md | 3 + docs/file-batching.md | 11 + ...71\351\207\217\346\223\215\344\275\234.md" | 8 + native/crates/ops-files/src/file_path.rs | 7 + .../crates/ops-files/src/file_path_tests.rs | 30 +++ native/crates/ops-search/src/scan.rs | 6 + test/audit-store-paths.test.mjs | 242 ++++++++++++++++++ 7 files changed, 307 insertions(+) create mode 100644 native/crates/ops-files/src/file_path_tests.rs create mode 100644 test/audit-store-paths.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffef6522..565741e7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Reserve the exact `.dsh-ops` component case-insensitively on absolute public file-tool paths as well as relative batch paths. Reads, writes, edits, copy/move sources and destinations, removals, and explicit search/replace paths return `PATH_RESTRICTED` before content access or publication. Preserve ordinary hidden-search exclusions and trusted batch audit/result/cache lifecycle access (#329). +- 绝对路径公开文件工具与相对 batch 通道统一将完整组件 `.dsh-ops` 视为保留名,忽略大小写。读取、写入、编辑、复制/移动的源与目标、删除及显式搜索/替换路径均在内容访问或发布前返回 `PATH_RESTRICTED`;保留普通隐藏搜索排除规则,以及可信 batch 审计、结果和缓存生命周期访问(#329)。 + ## [0.4.1] — 2026-10-20 — Update locks stop leaving files behind - Stop the update lock from leaving `.dsh-ops-update-*.lock` sidecars behind (#319). The writer that takes the lock removes it on release, and only after proving the name still leads to the inode it locked, so a waiter that opened the old inode can never publish under it. A published write, a request refused after the lock and a directory-wide `replace` now leave the directory exactly as they found it; a sidecar left by a killed writer is not a stuck lock — the next write of that name binds to it, publishes under it and removes it. diff --git a/docs/file-batching.md b/docs/file-batching.md index 79305af6..bc4c6dec 100644 --- a/docs/file-batching.md +++ b/docs/file-batching.md @@ -22,6 +22,17 @@ checks every path and refuses links/junctions. Read-only calls create no audit o Successful observations update the host read-before-edit table using versions captured before reading; failed and pending entries do not produce observations. +The exact path component `.dsh-ops` is reserved case-insensitively on every platform, including +absolute paths, ancestors above the session cwd, and missing create targets. Public file reads +(including document readers, `stat`, `diff` and each `read_files` entry), writes, edits, copies, +moves and removals reject it with `PATH_RESTRICTED`; copy/move check both source and destination. +An explicitly requested search/replace path follows the same rule. Ordinary directory searches +continue to exclude hidden audit entries without treating that exclusion as a coverage gap. +Near names such as `.dsh-ops-backup` and `.dsh-ops.json` are not reserved, though normal hidden +search exclusions still apply. This public-path restriction does not block trusted batch +plan/run/resume, audit paging, or result/cache lifecycle APIs. It does not authenticate audit +records against writes made outside these public file tools. + `nextIndex` is the first unreturned input entry and `pending` counts remaining entries. Resume those files by sending the remaining slice of the original `files` array. Separately, an included file's `complete: false` and `nextOffset` describe unread lines in that file. Aggregate `complete` diff --git "a/docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" "b/docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" index 9ad8422e..d604261e 100644 --- "a/docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" +++ "b/docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" @@ -17,6 +17,14 @@ English: [file-batching.md](file-batching.md). 验证路径并拒绝符号链接/junction。只读调用不创建审计或缓存文件。成功项使用读取前观察到的版本更新 宿主的“读后编辑”记录;失败项与尚未读取的项不更新该记录。 +所有平台都将完整路径组件 `.dsh-ops` 视为保留名,忽略 ASCII 大小写;绝对路径、会话 cwd 上方的 +祖先组件及尚不存在的创建目标也适用。公开文件读取(包括文档读取器、`stat`、`diff` 及每个 +`read_files` 项)、写入、编辑、复制、移动和删除均以 `PATH_RESTRICTED` 拒绝;复制和移动同时 +检查源与目标。显式指定的搜索/替换路径遵循同一规则。普通目录搜索仍排除隐藏审计项,不将这种 +排除视为覆盖缺口。`.dsh-ops-backup`、`.dsh-ops.json` 等近似名称不是保留名,但仍受通常的隐藏 +搜索排除规则约束。此公开路径限制不阻断可信的 batch plan/run/resume、审计分页及结果/缓存生命 +周期 API,也不对绕过公开文件工具写入的审计记录提供真实性认证。 + `nextIndex` 指向输入数组中首个未返回项,`pending` 是剩余项数。继续读取时提交原始 `files` 数组的 剩余片段。每个已返回文件还有独立的 `complete` 与 `nextOffset`,表示该文件尚未读取的行。只有所有 文件均已完整读取、没有错误且没有待处理项,顶层 `complete` 才为 true。`pending: 0` 的部分结果仍 diff --git a/native/crates/ops-files/src/file_path.rs b/native/crates/ops-files/src/file_path.rs index ca7d0551..1513cbad 100644 --- a/native/crates/ops-files/src/file_path.rs +++ b/native/crates/ops-files/src/file_path.rs @@ -6,6 +6,10 @@ use cap_fs_ext::OsMetadataExt; use cap_std::{ambient_authority, fs::{Dir, File, Metadata, OpenOptions}}; use std::path::{Component, Path, PathBuf}; +#[cfg(test)] +#[path = "file_path_tests.rs"] +mod audit_tests; + pub struct Target { pub parent: Dir, pub name: String, pub display: PathBuf } pub fn checked(raw: &str) -> Result { checked_leaf(raw, false) @@ -63,6 +67,9 @@ pub fn validate_name(name: &str) -> Result<()> { if name.is_empty() || name == "." || name == ".." || name.contains('\0') { return Err(Failure::new("INVALID_ARGUMENT", format!("Invalid path component {name:?}"))); } + if name.eq_ignore_ascii_case(crate::paths::STORE) { + return Err(Failure::new("PATH_RESTRICTED", "Audit storage is reserved for internal operations")); + } #[cfg(windows)] { let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase(); diff --git a/native/crates/ops-files/src/file_path_tests.rs b/native/crates/ops-files/src/file_path_tests.rs new file mode 100644 index 00000000..95c64838 --- /dev/null +++ b/native/crates/ops-files/src/file_path_tests.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn audit_components_are_reserved_case_insensitively() { + for name in [".dsh-ops", ".DsH-OpS", ".DSH-OPS"] { + assert_eq!(validate_name(name).unwrap_err().code, "PATH_RESTRICTED"); + assert_eq!(crate::paths::relative(name).unwrap_err().code, "PATH_RESTRICTED"); + } +} + +#[test] +fn audit_component_rejection_precedes_missing_ancestor_io() { + let root = std::env::temp_dir().join(format!("dsh-ops-missing-{}", uuid::Uuid::new_v4())); + for suffix in [".DSH-OPS", ".dsh-ops/new", "nested/.DsH-OpS/new"] { + let path = root.join(suffix); + match checked_leaf(path.to_str().unwrap(), true) { + Err(error) => assert_eq!(error.code, "PATH_RESTRICTED"), + Ok(_) => panic!("reserved path was accepted"), + } + } + assert!(!root.exists()); +} + +#[test] +fn near_audit_names_are_not_reserved_components() { + for name in [".dsh-ops-backup", ".dsh-ops.json", "dsh-ops", ".dsh-ops-update-lock"] { + validate_name(name).unwrap(); + crate::paths::relative(name).unwrap(); + } +} diff --git a/native/crates/ops-search/src/scan.rs b/native/crates/ops-search/src/scan.rs index 87ee58bf..151cc147 100644 --- a/native/crates/ops-search/src/scan.rs +++ b/native/crates/ops-search/src/scan.rs @@ -97,6 +97,12 @@ impl Scan { for name in names { if !self.running() { break; } let child = display.join(&name); + // Reserved audit entries remain intentional exclusions, not scan gaps. + if name.eq_ignore_ascii_case(ops_files::paths::STORE) { + let file = directory.symlink_metadata(&name).is_ok_and(|metadata| metadata.is_file()); + self.skip("excluded", file, false); + continue; + } if file_path::validate_name(&name).is_err() { self.skip("invalid-path", false, true); continue; } let metadata = match directory.symlink_metadata(&name) { Ok(metadata) => metadata, Err(_) => { self.skip("unreadable-entry", false, true); continue; } diff --git a/test/audit-store-paths.test.mjs b/test/audit-store-paths.test.mjs new file mode 100644 index 00000000..688a4f90 --- /dev/null +++ b/test/audit-store-paths.test.mjs @@ -0,0 +1,242 @@ +/** Public paths cannot address private audit records; trusted lifecycle APIs still can. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash, randomUUID } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callTool, callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' + +requireWorker('audit-store-paths') +const content = 'reserved needle\n' +const hash = value => createHash('sha256').update(value).digest('hex') +const call = async (root, tool, args, options = {}) => + envelopeOf(await callTool(tool, args, { root, writeRoots: [root], ...options }), assert) +const denied = value => { + assert.equal(value.status, 'error', JSON.stringify(value)) + assert.equal(value.error.code, 'PATH_RESTRICTED', JSON.stringify(value)) + assert.equal(value.error.effectState, 'none', JSON.stringify(value)) + assert.equal(value.error.retryable, false) +} +const snapshot = root => fs.readdirSync(root, { recursive: true }).sort().map(name => { + const file = path.join(root, name) + return [name, fs.statSync(file).isFile() ? fs.readFileSync(file).toString('hex') : null] +}) +const variants = [ + ['lowercase ancestor', ['.dsh-ops', 'record.txt']], + ['mixed-case nested ancestor', ['nested', '.DsH-OpS', 'deep', 'record.txt']], + ['uppercase final component', ['final', '.DSH-OPS']], +] +const fixture = (parts, body) => withTempDir('audit-paths', async root => { + const target = path.join(root, ...parts), ordinary = path.join(root, 'ordinary.txt') + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, content) + fs.writeFileSync(ordinary, content) + await body(root, target, ordinary) +}) +const operations = [ + ['write_file', (target) => ({ path: target, content: 'changed', expectedRevision: hash(content) })], + ['edit_file', (target) => ({ path: target, edits: [{ oldText: 'needle', newText: 'changed' }], expectedRevision: hash(content) })], + ['copy source', (target, ordinary, root) => ({ path: target, to: path.join(root, 'copied.txt'), expectedRevision: hash(content) })], + ['copy destination', (target, ordinary) => ({ path: ordinary, to: target, expectedRevision: hash(content) })], + ['move source', (target, ordinary, root) => ({ path: target, to: path.join(root, 'moved.txt'), expectedRevision: hash(content) })], + ['move destination', (target, ordinary) => ({ path: ordinary, to: target, expectedRevision: hash(content) })], + ['remove', target => ({ path: target, expectedRevision: hash(content) })], + ['inspect_local_file', target => ({ path: target })], + ['stat', target => ({ path: target })], + ['diff', target => ({ path: target, content: 'changed' })], + ['read_table', target => ({ path: target })], + ['read_docx', target => ({ path: target })], + ['read_pdf', target => ({ path: target })], + ['grep', target => ({ path: target, pattern: 'needle' })], + ['replace', target => ({ path: target, pattern: 'needle', replacement: 'changed' })], +] + +for (const [label, parts] of variants) { + for (const [operation, args] of operations) { + await test(`${operation} refuses ${label} without filesystem effects`, async () => { + await fixture(parts, async (root, target, ordinary) => { + // Destinations must be absent so no-clobber cannot mask the reserved-path error. + if (operation.endsWith('destination')) fs.unlinkSync(target) + const before = snapshot(root) + const value = await call(root, operation.split(' ')[0], args(target, ordinary, root)) + assert.deepEqual(snapshot(root), before, JSON.stringify(value)) + denied(value) + }) + }) + } + await test(`read_files refuses ${label} per item while retaining ordinary neighbors`, async () => { + await fixture(parts, async (root, target, ordinary) => { + const before = snapshot(root) + const value = await call(root, 'read_files', { files: [{ path: ordinary }, { path: target }, { path: ordinary }] }, { readOnly: true }) + assert.equal(value.status, 'partial') + assert.equal(value.complete, false) + assert.equal(value.failed, 1) + assert.equal(value.pending, 0) + denied(value.items[1]) + assert.deepEqual(value.items[1].items, []) + assert.equal(value.items[0].revision, hash(content)) + assert.equal(value.items[2].revision, hash(content)) + assert.deepEqual(snapshot(root), before) + }) + }) + for (const operation of ['write_file', 'stat']) { + await test(`${operation} refuses missing ${label} rather than accepting absent`, async () => { + await fixture(parts, async (root, target) => { + fs.unlinkSync(target) + const before = snapshot(root) + const args = operation === 'stat' ? { path: target } : { path: target, content, expectedRevision: 'absent' } + denied(await call(root, operation, args)) + assert.deepEqual(snapshot(root), before) + }) + }) + } +} + +for (const spelling of ['.dsh-ops', '.DsH-OpS']) { + await test(`glob refuses an explicitly requested ${spelling} directory`, async () => { + await fixture([spelling, 'record.txt'], async (root, target) => { + const before = snapshot(root) + denied(await call(root, 'glob', { path: path.dirname(target), pattern: ['**/*'] })) + denied(await call(root, 'grep', { path: path.dirname(target), pattern: 'needle' })) + denied(await call(root, 'replace', { path: path.dirname(target), pattern: 'needle', replacement: 'changed' })) + assert.deepEqual(snapshot(root), before) + }) + }) +} + +await test('reserved ancestors above the session cwd are not an alternate entrance', async () => { + await fixture(['.DsH-OpS', 'workspace', 'record.txt'], async (root, target) => { + const before = snapshot(root), cwd = path.dirname(target) + denied(await call(cwd, 'inspect_local_file', { path: target })) + denied(await call(cwd, 'write_file', { path: target, content: 'changed', expectedRevision: hash(content) })) + assert.deepEqual(snapshot(root), before) + }) +}) + +await test('ordinary search excludes hidden audit directories without introducing a coverage gap', async () => { + await withTempDir('audit-search', async root => { + fs.writeFileSync(path.join(root, 'visible.txt'), content) + for (const name of ['.dsh-ops', 'nested/.DsH-OpS', '.dsh-ops-backup']) { + fs.mkdirSync(path.join(root, name), { recursive: true }) + fs.writeFileSync(path.join(root, name, 'record.txt'), content) + } + const before = snapshot(root) + for (const [tool, args] of [ + ['glob', { pattern: ['**/*'] }], + ['grep', { pattern: 'needle' }], + ]) { + const value = await call(root, tool, { path: root, ...args }) + assert.equal(value.status, 'completed', JSON.stringify(value)) + assert.equal(value.complete, true, JSON.stringify(value)) + assert.equal(value.items.length, 1) + assert.ok(JSON.stringify(value.items).includes('visible.txt')) + assert.equal(value.skipReasons?.['invalid-path'] ?? 0, 0) + } + assert.deepEqual(snapshot(root), before) + const replaced = await call(root, 'replace', { path: root, pattern: 'needle', replacement: 'changed' }) + assert.equal(replaced.status, 'completed', JSON.stringify(replaced)) + assert.equal(fs.readFileSync(path.join(root, 'visible.txt'), 'utf8'), 'reserved changed\n') + for (const [name, bytes] of before) { + if (bytes !== null && name !== 'visible.txt') assert.equal(fs.readFileSync(path.join(root, name)).toString('hex'), bytes) + } + }) +}) + +await test('near names remain usable through real write/edit/read/stat/copy/move/remove/search calls', async () => { + await withTempDir('audit-near', async root => { + const dir = path.join(root, '.dsh-ops-backup'); fs.mkdirSync(dir) + const target = path.join(dir, '.dsh-ops.json') + assert.equal((await call(root, 'write_file', { path: target, content, expectedRevision: 'absent' })).status, 'completed') + assert.equal((await call(root, 'edit_file', { path: target, expectedRevision: hash(content), edits: [{ oldText: 'needle', newText: 'changed' }] })).status, 'completed') + const revision = hash('reserved changed\n') + assert.equal((await call(root, 'stat', { path: target })).revision, revision) + assert.equal((await call(root, 'inspect_local_file', { path: target })).revision, revision) + assert.equal((await call(root, 'read_files', { files: [{ path: target }] })).items[0].revision, revision) + assert.equal((await call(root, 'diff', { path: target, content })).status, 'completed') + assert.equal((await call(root, 'grep', { path: target, pattern: 'changed' })).items.length, 1) + assert.equal((await call(root, 'glob', { path: dir, pattern: ['**/*'] })).status, 'completed') + const copy = path.join(dir, 'copied'), moved = path.join(dir, 'moved') + assert.equal((await call(root, 'copy', { path: target, to: copy, expectedRevision: revision })).status, 'completed') + assert.equal((await call(root, 'move', { path: copy, to: moved, expectedRevision: revision })).status, 'completed') + assert.equal((await call(root, 'remove', { path: moved, expectedRevision: revision })).status, 'completed') + assert.equal(fs.existsSync(copy), false) + assert.equal(fs.existsSync(moved), false) + }) +}) + +await test('relative batch source, destination and protect paths retain PATH_RESTRICTED', async () => { + await withTempDir('audit-relative', async root => { + fs.writeFileSync(path.join(root, 'ordinary.txt'), content) + for (const reserved of ['.dsh-ops', 'nested/.DsH-OpS/record.txt']) { + for (const args of [ + { operations: [{ op: 'remove', path: reserved }] }, + { operations: [{ op: 'copy', path: 'ordinary.txt', to: reserved }] }, + { operations: [{ op: 'move', path: reserved, to: 'moved' }] }, + { operations: [{ op: 'remove', path: 'ordinary.txt' }], protect: [reserved] }, + ]) denied(await call(root, 'batch', { action: 'plan', ...args })) + } + assert.equal(fs.readFileSync(path.join(root, 'ordinary.txt'), 'utf8'), content) + }) +}) + +await test('public write cannot plant a forged pending manifest for batch resume (#329)', async () => { + await withTempDir('audit-forgery', async root => { + const victim = path.join(root, 'victim.txt'); fs.writeFileSync(victim, content) + const plan = await call(root, 'batch', { action: 'plan', operations: [{ op: 'remove', path: 'victim.txt' }] }) + assert.equal(plan.status, 'planned', JSON.stringify(plan)) + const manifest = JSON.parse(fs.readFileSync(path.join(root, '.dsh-ops', `${plan.id}.000000.json`), 'utf8')) + const id = `j-${randomUUID()}`, target = path.join(root, '.dsh-ops', `${id}.000000.json`) + manifest.id = id + const before = snapshot(root) + denied(await call(root, 'write_file', { path: target, content: JSON.stringify(manifest), expectedRevision: 'absent' })) + assert.deepEqual(snapshot(root), before) + const resumed = await call(root, 'batch', { action: 'resume', id }) + assert.equal(resumed.error.code, 'NOT_FOUND', JSON.stringify(resumed)) + assert.equal(fs.readFileSync(victim, 'utf8'), content) + }) +}) + +await test('trusted batch plan/resume and legacy result put/page/release retain audit access', async () => { + await withTempDir('audit-lifecycle', async root => { + const victim = path.join(root, 'victim.txt'); fs.writeFileSync(victim, content) + const plan = await call(root, 'batch', { action: 'plan', operations: [{ op: 'remove', path: 'victim.txt' }] }) + assert.equal(plan.status, 'planned', JSON.stringify(plan)) + const done = await call(root, 'batch', { action: 'resume', id: plan.id }) + assert.equal(done.status, 'completed', JSON.stringify(done)) + assert.equal(done.done, 1) + assert.equal(fs.existsSync(victim), false) + assert.equal((await call(root, 'batch', { action: 'resume', id: plan.id })).done, 1) + const audit = await call(root, 'result', { action: 'page', id: plan.id }, { readOnly: true }) + assert.equal(audit.complete, true) + assert.equal(audit.items[0].state, 'completed') + fs.writeFileSync(victim, content) + const run = await call(root, 'batch', { action: 'run', operations: [{ op: 'copy', path: 'victim.txt', to: 'copy.txt' }] }) + assert.equal(run.status, 'completed', JSON.stringify(run)) + assert.equal(fs.readFileSync(path.join(root, 'copy.txt'), 'utf8'), content) + const stored = await call(root, 'result', { action: 'put', items: [{ trusted: true }] }) + assert.ok(stored.id?.startsWith('r-'), JSON.stringify(stored)) + const page = await call(root, 'result', { action: 'page', id: stored.id }, { readOnly: true }) + assert.deepEqual(page.items, [{ trusted: true }]) + assert.equal((await call(root, 'result', { action: 'release', id: stored.id })).status, 'released') + assert.ok(fs.readdirSync(path.join(root, '.dsh-ops')).some(name => name.startsWith(plan.id))) + }) +}) + +await test('trusted session cache put/page/release remains independent of the public path guard', async () => { + await withTempDir('audit-cache', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }) + const invoke = async args => envelopeOf(await callWorker(JSON.stringify({ + version: 1, requestId: 'audit-cache', component: 'ptc', + budget: { timeoutMs: 30000, maxBytes: 131072 }, + storage: { root: cache, authorityId: 'a'.repeat(64), allowedTools: ['result', 'batch'] }, + request: request('result', args, { root, writeRoots: [] }), + })), assert) + const stored = await invoke({ action: 'put', items: [{ trusted: 'cache' }] }) + assert.equal(stored.status, 'completed', JSON.stringify(stored)) + assert.ok(stored.id.startsWith('ref://')) + assert.deepEqual((await invoke({ action: 'page', id: stored.id })).items, [{ trusted: 'cache' }]) + assert.equal((await invoke({ action: 'release', id: stored.id })).status, 'released') + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + }) +}) + +report('audit-store-paths') From 947eb46c6bdd4ba5050bcaab554b906aa6e7182a Mon Sep 17 00:00:00 2001 From: chimney Date: Wed, 21 Oct 2026 03:31:23 +0800 Subject: [PATCH 02/50] fix(discovery): avoid reprobing unchanged agents on attach (#333) --- docs/CHANGELOG.md | 3 + docs/discovery.md | 12 +- ...45\345\205\267\345\217\221\347\216\260.md" | 12 +- src/discovery/index.ts | 62 ++- src/file/runtime.ts | 4 +- test/discovery-incremental.test.mjs | 360 ++++++++++++++++++ 6 files changed, 440 insertions(+), 13 deletions(-) create mode 100644 test/discovery-incremental.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffef6522..7ffde37b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Reuse unchanged discovery presentation decisions during reconciliation instead of reassembling every existing agent when one is created (#333). Invalidate on live authority inputs, registry/configuration and ownership changes, and host-service lifecycle; keep pre-step and execution checks fresh. Avoid empty worker-provider notifications and retain asynchronous invalidation and unload safety. +- 发现组件 reconcile 复用未变化的 presentation 判定,不再因新增一个 agent 而重组全部旧 agent(#333)。实时权限输入、注册表/配置、ownership 与宿主服务生命周期变化均使缓存失效;pre-step 和执行检查保持新鲜。避免空 worker provider 发送无效通知,保留异步失效和卸载安全性。 + ## [0.4.1] — 2026-10-20 — Update locks stop leaving files behind - Stop the update lock from leaving `.dsh-ops-update-*.lock` sidecars behind (#319). The writer that takes the lock removes it on release, and only after proving the name still leads to the inode it locked, so a waiter that opened the old inode can never publish under it. A published write, a request refused after the lock and a directory-wide `replace` now leave the directory exactly as they found it; a sidecar left by a killed writer is not a stuck lock — the next write of that name binds to it, publishes under it and removes it. diff --git a/docs/discovery.md b/docs/discovery.md index f76aae29..c81bf13c 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -30,9 +30,19 @@ Every activated file tool keeps its declared path base, argument schema, Native Only this plugin's scoped `ctx.tools.register`, `restrict`, prompt sections and lifecycle listeners are used. No host method or shared tool registry is replaced. Publication compares the desired tool names before changing registrations so it can coexist with PTC lifecycle listeners. +## Incremental presentation checks + +Adding an agent probes its presentation without reassembling unchanged agents. Background reconciliation reuses only a successful presentation decision, keyed by the agent context, session, cwd and freshly resolved sandbox policy (mode, workspace root and session identity). Starting the discovery worker with no resident tools does not emit a no-op tool-registry change. The inexpensive policy/ownership and desired-tool diff still visits existing entries; this is not a claim of constant-time reconciliation. + +`tools/change`, `system-prompt/change`, runtime configuration updates and discovery-row ownership changes invalidate decisions. The global runtime-update hook awaits `next()` before invalidating: the pinned host's default returns an asynchronous `Fiber.restart()` despite its `void` declaration, while an in-place update can be synchronous. The pinned host's registry notifications have no affected-scope payload, so genuine registry changes conservatively invalidate all entries, including a new scoped presentation declaration. Discovery registers its own prompt provider once per row, not once per agent. A session's `sandbox/mode` event invalidates only its entries. Required host-service unload removes the row's registrations and cache; reload rebuilds them. Optional PTC-runtime mount/unmount also refreshes decisions, and failed probes are not cached. + +Arbitrary dynamic prompt providers need not emit any change event. For that reason, each model pre-step and every tool execution performs a fresh check for its own agent; this cache never authorizes a call. Runtime policy and cwd remain live inputs, including when the same agent object is reused. Native and `both` can publish discovery; pure PTC cannot. Invalidation during an asynchronous probe retires that result before publication; queued work and unload cleanup remain authoritative. + +`node test/run.mjs discovery-incremental discovery-composition presentation-readiness progressive-loop` covers counting, invalidation, scoped ownership and lifecycle races. The incremental fixture adds one agent beside twelve existing agents and asserts **zero old-agent probes and one new-agent probe**; this is a deterministic assembly count, not a wall-clock benchmark. + ## Measurement boundaries -The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **94 tokens / 410 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. +The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **95 tokens / 416 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. The default file/discovery surface is now `ops_read_files` plus `ops_discover_tools`: **242 tokens / 1101 bytes** in actual host assembly order. The reader accepts multiple file ranges in one startup call; `ops_inspect_local_file` (including hex), search, mutations and Native result paging are discoverable. Routing names only published tools. The final Windows provider order with the full Bash/jobs contract measures **662 tokens / 3226 bytes** (the pre-rename snapshot was 660/3219; the older catalogue-order figure was 661 tokens). These full totals remain visible under the explicitly approved BM-03 scope: the ≤400-token gate applies to file/discovery schemas, while Bash remains at startup. Numeric bounds, defaults, path bases and permission contracts remain intact. Operator always-loaded choices expand the initial surface. See [benchmark evidence](benchmark-gates.md). diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index 1f3df7e0..43e5c6aa 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -30,9 +30,19 @@ English: [Advanced tools on demand](discovery.md). 只使用本插件自己的作用域 `ctx.tools.register`、`restrict`、提示词段落和生命周期监听;不替换宿主方法或共享工具注册表。发布前比较目标工具名称,避免与 PTC 生命周期监听器互相触发重复注册。 +## 增量 presentation 检查 + +新增 agent 时只探测它的 presentation,不重新组装未变化的旧 agent。后台 reconcile 仅复用成功的 presentation 判定,匹配 agent context、session、cwd 以及实时解析的沙箱策略(mode、workspace root、session identity)。发现组件以空常驻工具集启动 worker 时,不再发送无实际变化的工具注册表事件。低成本的策略、ownership 与目标工具差异检查仍遍历已有条目;这不代表 reconcile 整体变为常数时间。 + +`tools/change`、`system-prompt/change`、运行时配置更新和发现行 ownership 变更会使判定失效。全局 runtime-update hook 等待 `next()` 完成后再失效:固定宿主虽将其声明为 `void`,默认路径实际返回异步 `Fiber.restart()`;原地更新则可以同步完成。固定宿主版本的注册表通知没有受影响作用域参数,因此真实注册表变化会保守地使所有条目失效,包括新增作用域的 presentation 声明。发现组件自己的 prompt provider 每行只注册一次,而不是每个 agent 注册一次。会话的 `sandbox/mode` 事件只使自身条目失效。必需宿主服务卸载时移除本行注册及缓存,重载后重新建立;可选 PTC runtime 挂载、卸载也会刷新判定,探测失败不缓存。 + +任意动态提示词 provider 不保证发送变化事件,所以每次模型 pre-step 和每次工具执行仍对自己的 agent 做新鲜检查,缓存绝不用于授权调用。即使复用同一个 agent 对象,运行时策略与 cwd 也保持实时。Native 和 `both` 可以发布发现工具,纯 PTC 不发布。异步探测期间发生失效时,旧结果在发布前作废;后续排队任务和卸载清理仍具有最终效力。 + +`node test/run.mjs discovery-incremental discovery-composition presentation-readiness progressive-loop` 覆盖计数、失效、作用域 ownership 和生命周期竞争。增量 fixture 在十二个已有 agent 旁新增一个,断言**旧 agent 探测零次、新 agent 一次**;这是确定性的组装计数,不是耗时基准。 + ## 测量边界 -确定性的入口测量固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **94 tokens / 410 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 +确定性的入口测量固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **95 tokens / 416 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 默认文件/发现工具面为 `ops_read_files` 加 `ops_discover_tools`,**242 tokens / 1101 字节**。完整 Windows Bash/jobs 契约按供应商实际顺序为 **662 tokens / 3226 字节**(改名前快照为 660/3219,更早的目录顺序口径为 661 tokens)。已明确批准 BM-03 的 ≤400 token 门禁仅作用于文件/发现 schema;Bash 保持启动常驻,其成本及完整总数继续单列。高级读取、搜索、修改、文档与结果分页按需发现;路由只列出已发布工具。数值边界、默认值、路径基准与权限契约保持不变;始终加载项会扩大初始面。见[基准证据](基准门禁.md)。 diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 678fb2fd..9f390e22 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -26,6 +26,8 @@ interface Entry { disposers: (() => void)[] owned: Set ready: Promise + revision: number + presentation?: { inputs: unknown[]; native: boolean } releaseSelection?: () => void } const DISCOVER = publicToolName('discover_tools') @@ -68,7 +70,26 @@ export function apply(root: Context, raw: unknown): void { } catch { return false } } const ptcOnly = (assembly: PromptAssembly): boolean => assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) - const native = (agent: Agent): Promise => isNativePresentation(prompt, agent) + const invalidate = (entry: Entry) => { ++entry.revision; entry.presentation = undefined } + const inputs = (agent: Agent): unknown[] => { + const policy = policyService.resolve({ session: agent.session }) + return [agent.ctx, agent.session, agent.session.header.cwd, policy.mode, policy.workspaceRoot, policy.sessionId] + } + const same = (left: unknown[], right: unknown[]) => left.length === right.length && left.every((value, index) => value === right[index]) + // Only background reconciliation reuses a probe. A step or execution can + // observe arbitrary dynamic prompt providers with no registry notification. + const native = async (agent: Agent, fresh = false): Promise => { + const entry = entries.get(agent) + if (!entry) return false + if (fresh) invalidate(entry) + const current = inputs(agent), revision = entry.revision + if (entry.presentation && same(entry.presentation.inputs, current)) return entry.presentation.native + const result = await isNativePresentation(prompt, agent) + if (!stopped && entries.get(agent) === entry && revision === entry.revision && same(current, inputs(agent))) { + entry.presentation = { inputs: current, native: result } + } + return result + } const own = async (agent: Agent, entry: Entry, tool: ToolDefinition): Promise => { if (!entry.ctx) return const execute = tool.execute @@ -76,30 +97,32 @@ export function apply(root: Context, raw: unknown): void { await chain const version = epoch const available = () => ownership?.owns(agent) && !stopped && entries.get(agent) === entry && exec.agent === agent && entry.owned.has(tool.name) - if (!available() || !await native(agent).catch(() => false) || !available() || version !== epoch) { + if (!available() || !permitted(agent) || !await native(agent, true).catch(() => false) || !available() || version !== epoch) { return failure('POLICY_DENIED', 'Discovery capability belongs to a different or inactive scope.') } return execute(args, exec) } } - entry.disposers.push(await registerNativeProvider(entry.ctx, [registration], tool => entry.ctx!.tools.register(tool), { inheritable: false, priority: 10 })) + const dispose = await registerNativeProvider(entry.ctx, [registration], tool => entry.ctx!.tools.register(tool), { inheritable: false, priority: 10 }) + if (stopped || entries.get(agent) !== entry || !ownership?.owns(agent)) { dispose(); return } + entry.disposers.push(dispose) entry.owned.add(tool.name) } const inheritedNative = (agent: Agent, entry: Entry): string[] => nativeProviders(ctx, agent) .map(provider => provider.definition.name).filter(name => (advanced.includes(name) || name === DISCOVER) && !entry.owned.has(name)) const reconcile = (): Promise => { - if (stopped || mutating) return chain + if (stopped) return chain const version = ++epoch chain = chain.then(async () => { if (stopped || version !== epoch) return - const ready: [Agent, Entry][] = [] - for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && permitted(agent) && await native(agent).catch(() => false)) ready.push([agent, entry]) + const ready = new Set() + for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && permitted(agent) && await native(agent).catch(() => false)) ready.add(agent) if (stopped || version !== epoch) return mutating = true; ownership?.begin() try { for (const [agent, entry] of entries) { if (!ownership?.owns(agent)) { clear(entry); entry.releaseSelection?.(); entry.releaseSelection = undefined; continue } entry.releaseSelection ??= claimNativeSelection(ctx, agent, new Set(advanced)) - const isReady = ready.some(([candidate]) => candidate === agent) + const isReady = ready.has(agent) const wanted = isReady ? [...new Set([...config.alwaysLoaded, ...entry.selected])].filter(tool => permitted(agent, tool)) : [] const desired = new Set([...(isReady && config.enabled ? [DISCOVER] : []), ...wanted.map(publicToolName)]) const inherited = inheritedNative(agent, entry) @@ -142,7 +165,7 @@ export function apply(root: Context, raw: unknown): void { const attach = (agent: Agent): Promise => { if (stopped || entries.has(agent) || !agent.ctx) return entries.get(agent)?.ready ?? Promise.resolve() const entry: Entry = { runtime: new NativeFileTools({ ctx, config: { ...config, workerPath: worker }, report: undefined }), - selected: new Set(), pending: new Set(), disposers: [], owned: new Set(), ready: Promise.resolve() } + selected: new Set(), pending: new Set(), disposers: [], owned: new Set(), ready: Promise.resolve(), revision: 0 } entries.set(agent, entry); ownership?.track(agent) entry.fiber = agent.ctx.plugin({ inject: ['tools'], apply(scoped) { entry.ctx = scoped } }) entry.ready = Promise.resolve(entry.fiber).then(async () => { await entry.runtime.start([]); await reconcile() }) @@ -152,7 +175,7 @@ export function apply(root: Context, raw: unknown): void { ctx.on('agent/pre-step', async (payload, next) => { await attach(payload.agent) const entry = entries.get(payload.agent) - if (entry && ownership?.owns(payload.agent) && !payload.signal.aborted && await native(payload.agent).catch(() => false)) { + if (entry && ownership?.owns(payload.agent) && !payload.signal.aborted && await native(payload.agent, true).catch(() => false)) { for (const tool of entry.pending) if (permitted(payload.agent, tool)) entry.selected.add(tool) entry.pending.clear() await reconcile() @@ -166,7 +189,24 @@ export function apply(root: Context, raw: unknown): void { try { clear(entry); entry.releaseSelection?.() } finally { mutating = false } await entry.runtime.stop(); await entry.fiber?.dispose(); await reconcile() }) - ctx.on('tools/change', () => { if (!ownership?.publishing()) void reconcile() }) + const refresh = () => { + if (stopped) return + for (const entry of entries.values()) invalidate(entry) + void reconcile() + } + ctx.on('tools/change', () => { if (!mutating && !ownership?.publishing()) refresh() }) + ctx.on('system-prompt/change', refresh) + // Observe in-place hooks and the default asynchronous Fiber.restart(). + // Await the runtime result even though the pinned next() type says void. + ctx.on('internal/update', async (_config, _noSave, next) => { + try { return await next() } finally { refresh() } + }, { global: true, prepend: true }) + // This optional service is not an injection prerequisite: native agents + // work without it, while failed PTC probes must recover when it arrives. + ctx.inject(['ptcRuntime'], inner => { + refresh() + inner.effect(() => () => { refresh() }) + }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { // The pinned host assembles BEFORE agent/pre-step. Wait for publication // queued by the previous committed step, then obtain a fresh public assembly. @@ -202,12 +242,14 @@ export function apply(root: Context, raw: unknown): void { mutating = true try { for (const [agent, entry] of entries) if (agent.session === session) { + invalidate(entry) entry.pending.clear(); entry.runtime.cancel('Sandbox authority changed'); clear(entry) } } finally { mutating = false } void reconcile() }) ownership = await ownScopes(ctx, 'discovery', () => { + for (const entry of entries.values()) invalidate(entry) mutating = true try { for (const [agent, entry] of entries) if (!ownership?.owns(agent)) { diff --git a/src/file/runtime.ts b/src/file/runtime.ts index 18581534..86d75f64 100644 --- a/src/file/runtime.ts +++ b/src/file/runtime.ts @@ -43,7 +43,9 @@ export class NativeFileTools { return [] } const definitions = names.map(rawName => this.definition(rawName)) - this.unregisterProvider = await registerNativeProvider(this.ctx, definitions, tool => this.ctx.tools.register(tool)) + // Discovery starts a worker without a resident surface. Do not emit a + // registry change for an empty provider on every newly attached agent. + if (definitions.length) this.unregisterProvider = await registerNativeProvider(this.ctx, definitions, tool => this.ctx.tools.register(tool)) return names.map(publicToolName) } diff --git a/test/discovery-incremental.test.mjs b/test/discovery-incremental.test.mjs new file mode 100644 index 00000000..85288667 --- /dev/null +++ b/test/discovery-incremental.test.mjs @@ -0,0 +1,360 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createScope } from '@deepseek-ai/dsh-scope' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import * as discovery from '../lib/discovery/index.js' +import { isPresentationProbe } from '../lib/presentation.js' +import { assert, test, report, waitFor, withTempDir } from './lib/harness.mjs' +import { bootHost, scopedToolNames, shutdown } from './lib/host.mjs' +import { WORKER, requireWorker } from './lib/boundary.mjs' + +requireWorker('discovery-incremental') +const DISCOVER = 'ops_discover_tools' +const tick = () => new Promise(resolve => setImmediate(resolve)) +const visible = (ctx, agent, name = DISCOVER) => scopedToolNames(ctx, agent).includes(name) +const step = (ctx, agent) => ctx.waterfall('agent/pre-step', { + agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal, +}, async () => ({ kind: 'continue' })) +const call = (ctx, agent, name, args) => ctx.get('tools').execute({ + agent, name, arguments: args, callId: 'incremental', signal: new AbortController().signal, +}) + +async function setup(dir, config = {}) { + const { ctx } = await bootHost() + const agents = [], counts = new Map() + ctx.provide('agents', { list: () => agents }) + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox) + const policyRow = await ctx.plugin(inner => inner.provide('sandboxPolicy', { + resolve: ({ session } = {}) => ({ + mode: session?.mode ?? 'workspace-write', workspaceRoot: session?.header.cwd ?? dir, sessionId: session?.id, + }), + })) + const prompt = ctx.get('systemPrompt') + prompt.section({ name: 'incremental-counter', order: 10, text: context => { + if (isPresentationProbe(context)) counts.set(context.scope, (counts.get(context.scope) ?? 0) + 1) + return '' + } }) + const mount = (target = ctx, extra = {}) => target.plugin({ + apply(inner) { discovery.apply(inner, { binaryPath: WORKER, required: true, ...config, ...extra }) }, + }) + const row = await mount() + async function mint(parent) { + const id = 'incremental-' + agents.length + const agent = { id, session: { id, mode: 'workspace-write', header: { cwd: dir } } } + await (parent?.ctx ?? ctx).plugin({ inject: ['tools'], apply(inner) { + agent.ctx = createScope(inner, agent, parent ? { parent } : undefined).ctx + } }) + agents.push(agent) + await ctx.serial('agent/created', { agent }) + return agent + } + const flush = async agent => { await prompt.assemble({ scope: agent }); await tick() } + return { ctx, prompt, row, policyRow, agents, counts, mint, mount, flush } +} + +await test('adding an agent probes it once and never reassembles unchanged agents', () => withTempDir('discovery-incremental', async dir => { + const host = await setup(dir) + const { ctx, counts, mint, flush } = host + try { + const old = [] + for (let i = 0; i < 12; i++) old.push(await mint()) + await flush(old[0]); counts.clear() + let promptChanges = 0, toolChanges = 0 + ctx.on('system-prompt/change', () => { ++promptChanges }) + ctx.on('tools/change', () => { ++toolChanges }) + const added = await mint() + await flush(added) + const oldCount = old.reduce((sum, agent) => sum + (counts.get(agent) ?? 0), 0) + console.log(` added agent: old=${oldCount}, new=${counts.get(added) ?? 0}, old agents=${old.length}`) + console.log(` real registration events: prompt=${promptChanges}, tools=${toolChanges}`) + assert.equal(oldCount, 0, 'agent/created must not reassemble unrelated agents') + assert.equal(counts.get(added), 1) + assert.equal(promptChanges, 0, 'the per-row prompt provider must not be registered per agent') + assert.ok(toolChanges > 0, 'the counted path must include real host tool registration') + assert.ok(old.every(agent => visible(ctx, agent)) && visible(ctx, added)) + counts.clear() + await ctx.serial('agent/disposed', { agent: added }); await flush(old[0]) + assert.equal([...counts.values()].reduce((a, b) => a + b, 0), 0, 'disposal does not invalidate surviving presentation') + } finally { await shutdown(ctx) } +})) + +await test('a pre-step probes only its own agent once while committing pending tools', () => withTempDir('discovery-incremental', async dir => { + const { ctx, counts, mint, flush } = await setup(dir) + try { + const owner = await mint(), other = await mint() + await call(ctx, owner, DISCOVER, { query: 'select:ops_stat' }) + await flush(owner); counts.clear() + await step(ctx, owner); await flush(owner) + console.log(` pre-step: owner=${counts.get(owner) ?? 0}, other=${counts.get(other) ?? 0}`) + assert.equal(counts.get(owner), 1, 'pre-step and its reconcile share one presentation probe') + assert.equal(counts.get(other) ?? 0, 0) + assert.equal(visible(ctx, owner, 'ops_stat'), true) + assert.equal(visible(ctx, other, 'ops_stat'), false) + } finally { await shutdown(ctx) } +})) + +await test('prompt registry and runtime config changes invalidate cached presentation', () => withTempDir('discovery-incremental', async dir => { + const { ctx, prompt, mint, flush } = await setup(dir) + try { + const owner = await mint() + let ptc = false + const dynamic = await ctx.plugin({ inject: ['systemPrompt'], apply(inner) { + inner.on('internal/update', (config, _noSave, _next) => { ptc = config.ptc }) + inner.systemPrompt.section({ name: 'tools:ptc-only', order: 20, text: () => ptc ? 'PTC only' : '' }) + } }) + await flush(owner) + dynamic.update({ ptc: true }, true); await flush(owner) + assert.equal(visible(ctx, owner), false, 'in-place runtime update has no prompt registration event') + dynamic.update({ ptc: false }, true); await flush(owner) + assert.equal(visible(ctx, owner), true) + await dynamic.dispose() + const lift = prompt.section({ name: 'tools:ptc-only', order: 20, text: 'PTC only' }) + await flush(owner) + assert.equal(visible(ctx, owner), false) + lift(); await flush(owner) + assert.equal(visible(ctx, owner), true) + } finally { await shutdown(ctx) } +})) + +await test('runtime invalidation waits for the real asynchronous Fiber restart to finish', () => withTempDir('discovery-incremental', async dir => { + const { ctx, prompt, mint, flush } = await setup(dir) + let release + try { + const owner = await mint() + let ptc = false, entered + const entering = new Promise(resolve => { entered = resolve }) + const blocked = new Promise(resolve => { release = resolve }) + prompt.section({ name: 'tools:ptc-only', order: 20, text: () => ptc ? 'PTC only' : '' }) + const dynamic = await ctx.plugin({ async apply(_inner, config) { + if (config.ptc) { entered(); await blocked } + ptc = config.ptc + } }, { ptc: false }) + await flush(owner) + dynamic.update({ ptc: true }, true) + await entering + await flush(owner) + assert.equal(visible(ctx, owner), true, 'pending configuration still has its old value') + release(); await dynamic; await tick() + assert.equal(visible(ctx, owner), false, 'invalidate after next() resolves, not just after it returns a Promise') + dynamic.update({ ptc: false }, true) + await dynamic; await flush(owner) + assert.equal(visible(ctx, owner), true) + } finally { release?.(); await shutdown(ctx) } +})) + +await test('Native, PTC and both mode changes retain scoped ownership and restore on unload', () => withTempDir('discovery-incremental', async dir => { + const { ctx, mint, mount, flush } = await setup(dir) + try { + ctx.provide('ptcRuntime', { language: 'typescript' }) + const parent = await mint(), child = await mint(parent), sibling = await mint() + const lift = parent.ctx.tools.presentAs('ptc') + await flush(parent); await flush(child) + assert.equal(visible(ctx, parent), false); assert.equal(visible(ctx, child), false) + assert.equal(visible(ctx, sibling), true) + const both = child.ctx.tools.presentAs('both') + await flush(child) + assert.equal(visible(ctx, child), true) + const second = await mount(child.ctx, { alwaysLoaded: ['stat'] }) + await waitFor(() => visible(ctx, child, 'ops_stat')) + assert.equal(visible(ctx, sibling, 'ops_stat'), false) + const cached = ctx.get('tools').get('ops_stat', child) + await second.dispose(); await flush(child) + assert.equal(visible(ctx, child, 'ops_stat'), false) + assert.equal((await cached.execute({ path: '.' }, { agent: child })).status, 'rejected') + both(); lift(); await flush(parent); await flush(child) + assert.equal(visible(ctx, parent), true); assert.equal(visible(ctx, child), true) + } finally { await shutdown(ctx) } +})) + +await test('session authority changes refresh only that session and live calls use its cwd', () => withTempDir('discovery-incremental', async dir => { + const { ctx, counts, mint, flush } = await setup(dir, { alwaysLoaded: ['stat', 'write_file'] }) + try { + const owner = await mint(), other = await mint() + fs.writeFileSync(path.join(dir, 'sample'), 'first') + const childDir = path.join(dir, 'changed'); fs.mkdirSync(childDir) + fs.writeFileSync(path.join(childDir, 'sample'), 'different') + await flush(owner); counts.clear() + owner.session.mode = 'read-only'; owner.session.header.cwd = childDir + await ctx.serial('session/event', owner.session, { type: 'sandbox/mode', data: { mode: 'read-only' } }) + await flush(owner) + assert.equal(visible(ctx, owner, 'ops_write_file'), false) + assert.equal(visible(ctx, other, 'ops_write_file'), true) + assert.equal(counts.get(other) ?? 0, 0, 'session-local policy invalidation must not probe a sibling') + assert.equal((await call(ctx, owner, 'ops_stat', { path: 'sample' })).value.bytes, 9) + assert.equal((await call(ctx, owner, 'ops_write_file', { path: 'new', content: 'denied' })).isError, true) + } finally { await shutdown(ctx) } +})) + +await test('service unload and reload discard cached failures and stale registrations', () => withTempDir('discovery-incremental', async dir => { + const { ctx, policyRow, mint, flush } = await setup(dir) + try { + const owner = await mint() + const cached = ctx.get('tools').get(DISCOVER, owner) + await policyRow.dispose() + assert.equal(visible(ctx, owner), false) + assert.equal((await cached.execute({ query: 'stat' }, { agent: owner })).status, 'rejected') + await ctx.plugin(inner => inner.provide('sandboxPolicy', { + resolve: ({ session }) => ({ mode: 'read-only', workspaceRoot: session.header.cwd, sessionId: session.id }), + })) + await flush(owner) + assert.equal(visible(ctx, owner), true) + assert.notEqual(ctx.get('tools').get(DISCOVER, owner), cached) + } finally { await shutdown(ctx) } +})) + +await test('same agent with changed session cwd or policy is reprobed even without an event', () => withTempDir('discovery-incremental', async dir => { + const { ctx, prompt, counts, mint, flush } = await setup(dir) + try { + const owner = await mint(), other = await mint() + prompt.section({ name: 'tools:ptc-only', order: 20, text: ({ scope }) => + scope.session.mode === 'read-only' || scope.session.header.cwd !== dir ? 'PTC only' : '' }) + await flush(owner); counts.clear() + owner.session.header.cwd = path.join(dir, 'changed') + await mint() + assert.equal(visible(ctx, owner), false) + assert.ok(counts.get(owner) > 0, 'changed cwd must invalidate the owner; withdrawal may rebuild its SDK') + assert.equal(counts.get(other) ?? 0, 0) + owner.session.header.cwd = dir; await mint() + assert.equal(visible(ctx, owner), true) + owner.session.mode = 'read-only'; await mint() + assert.equal(visible(ctx, owner), false) + owner.session.mode = 'workspace-write'; await mint() + assert.equal(visible(ctx, owner), true) + } finally { await shutdown(ctx) } +})) + +await test('external tool registration and disposal invalidate presentation, not just schemas', () => withTempDir('discovery-incremental', async dir => { + const { ctx, prompt, mint, flush } = await setup(dir) + try { + const owner = await mint(), tools = ctx.get('tools') + prompt.section({ name: 'tools:ptc-only', order: 20, text: () => tools.get('presentation_switch') ? 'PTC only' : '' }) + await flush(owner) + const lift = tools.register({ name: 'presentation_switch', description: '', parameters: { type: 'object' }, + output: { schema: {}, render: () => [] }, execute: () => ({}) }) + await flush(owner) + assert.equal(visible(ctx, owner), false) + lift(); await flush(owner) + assert.equal(visible(ctx, owner), true) + } finally { await shutdown(ctx) } +})) + +await test('unsignalled dynamic providers are fresh at pre-step and cached-definition execution', () => withTempDir('discovery-incremental', async dir => { + const { ctx, prompt, mint, flush } = await setup(dir) + try { + const owner = await mint() + let ptc = false + prompt.section({ name: 'tools:ptc-only', order: 20, text: () => ptc ? 'PTC only' : '' }) + await flush(owner) + const cached = ctx.get('tools').get(DISCOVER, owner) + ptc = true + assert.equal((await cached.execute({ query: 'stat' }, { agent: owner })).status, 'rejected') + assert.equal(visible(ctx, owner), false) + ptc = false + await step(ctx, owner) + assert.equal(visible(ctx, owner), true, 'no registry event is required at the model-step boundary') + ptc = true + await step(ctx, owner) + assert.equal(visible(ctx, owner), false) + } finally { await shutdown(ctx) } +})) + +await test('an invalidation during asynchronous assembly cannot publish the retired result', () => withTempDir('discovery-incremental', async dir => { + const { ctx, prompt, mint, flush } = await setup(dir) + let release + try { + const owner = await mint() + let ptc = false, hold = false, entered + const entering = new Promise(resolve => { entered = resolve }) + const blocked = new Promise(resolve => { release = resolve }) + prompt.section({ name: 'tools:ptc-only', order: 20, text: () => ptc ? 'PTC only' : '' }) + await flush(owner) + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (isPresentationProbe(context) && context.scope === owner && hold) { + hold = false; entered(); await blocked + } + return next() + }) + hold = true + await ctx.serial('tools/change'); await entering + ptc = true + await ctx.serial('tools/change') + release(); await flush(owner) + assert.equal(visible(ctx, owner), false) + ptc = false; await ctx.serial('tools/change'); await flush(owner) + assert.equal(visible(ctx, owner), true) + } finally { release?.(); await shutdown(ctx) } +})) + +await test('overlapping agent creation preserves new work without reprobes of old agents', () => withTempDir('discovery-incremental', async dir => { + const { ctx, counts, mint, flush } = await setup(dir) + let release + try { + const old = await mint() + await flush(old); counts.clear() + let entered, hold = true + const entering = new Promise(resolve => { entered = resolve }) + const blocked = new Promise(resolve => { release = resolve }) + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (isPresentationProbe(context) && context.scope !== old && hold) { + hold = false; entered(); await blocked + } + return next() + }) + const first = mint() + await entering + const second = mint() + release() + const added = await Promise.all([first, second]) + await flush(old) + assert.equal(counts.get(old) ?? 0, 0) + for (const agent of added) { + assert.equal(counts.get(agent), 1) + assert.equal(visible(ctx, agent), true) + } + } finally { release?.(); await shutdown(ctx) } +})) + +await test('optional PTC runtime mount and unmount refresh failed both-mode probes', () => withTempDir('discovery-incremental', async dir => { + const { ctx, mint, flush } = await setup(dir) + try { + const owner = await mint() + const lift = owner.ctx.tools.presentAs('both') + // Without a runtime the host rejects this presentation before its waterfall. + await waitFor(() => !visible(ctx, owner)) + const mountRuntime = () => ctx.plugin(inner => inner.provide('ptcRuntime', { language: 'typescript' })) + const runtime = await mountRuntime() + await flush(owner) + assert.equal(visible(ctx, owner), true) + await runtime.dispose() + await waitFor(() => !visible(ctx, owner)) + await mountRuntime(); await flush(owner) + assert.equal(visible(ctx, owner), true) + lift() + } finally { await shutdown(ctx) } +})) + +await test('unloading during a probe cannot resurrect registrations or leave change listeners', () => withTempDir('discovery-incremental', async dir => { + const { ctx, row, counts, mint } = await setup(dir) + let release + try { + const owner = await mint(), cached = ctx.get('tools').get(DISCOVER, owner) + let entered, hold = true + const entering = new Promise(resolve => { entered = resolve }) + const blocked = new Promise(resolve => { release = resolve }) + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (isPresentationProbe(context) && hold) { hold = false; entered(); await blocked } + return next() + }) + await ctx.serial('tools/change'); await entering + const closing = row.dispose() + release(); await closing + assert.equal(visible(ctx, owner), false) + assert.equal((await cached.execute({ query: 'stat' }, { agent: owner })).status, 'rejected') + counts.clear() + await ctx.serial('tools/change'); await ctx.serial('system-prompt/change'); await tick() + assert.equal(counts.size, 0) + } finally { release?.(); await shutdown(ctx) } +})) + +report('discovery-incremental') From 537df22b5a12c3d3b338956e8c2a066f8df61de6 Mon Sep 17 00:00:00 2001 From: ViscaOwO <2821930750@qq.com> Date: Wed, 21 Oct 2026 20:03:07 +0800 Subject: [PATCH 03/50] docs(provenance): name the object of every published checksum (#300) The 0.3.1 release notes headed the asset table "Target | SHA-256" and listed the worker digest; 0.3.2 kept the same heading and listed the ZIP digest, so a reader comparing an archive against the worker reports a false mismatch. Record the contract instead of the numbers: the SHA256SUMS asset is authoritative for the released tarballs, every tarball carries its native worker digest in runtime//provenance.json, and the Release body defines neither. Releases published from a maintainer machine have no run summary and did not always carry SHA256SUMS, so their record is the in-package receipt. The troubleshooting guides gain the in-package checker, which needs no platform-specific hashing tool, and docs/PROVENANCE.md records the historical mismatch rather than editing a published Release. --- docs/CHANGELOG.md | 2 ++ docs/PROVENANCE.md | 8 ++++++++ docs/release-process.md | 2 ++ docs/troubleshooting.md | 14 ++++++++++++++ ...\270\350\247\201\351\227\256\351\242\230.md" | 14 ++++++++++++++ test/docs-facts.test.mjs | 17 +++++++++++++++++ 6 files changed, 57 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7d3d1994..7ee2af52 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Name the bytes each published checksum covers (#300): `SHA256SUMS` is authoritative for the released tarballs, and every tarball records its native worker's digest in `runtime//provenance.json`; the Release body defines neither. The 0.3.1 notes headed that table `Target | SHA-256` and listed the worker digest, while 0.3.2 kept the heading and listed the ZIP digest, so anyone comparing archive against worker saw a false mismatch. The troubleshooting guides now carry the in-package check (`node package/bin/native-runtime.mjs check --root package --platform `), which needs no platform-specific hashing tool, and `docs/PROVENANCE.md` records the historical mismatch. Published Release bodies are left as they were. +- 说明已发布的摘要到底覆盖哪些字节(#300):`SHA256SUMS` 是发布 tarball 的权威,每个 tarball 在 `runtime//provenance.json` 里记录自带 native worker 的摘要,两者都不由 Release 正文定义。0.3.1 的正文把 `Target | SHA-256` 填成 worker 摘要,0.3.2 在同样表头下填成 ZIP 摘要,比对归档与 worker 的人因此看到假的不匹配。排查文档新增包内校验命令(`node package/bin/native-runtime.mjs check --root package --platform `),不依赖平台专有的哈希工具;`docs/PROVENANCE.md` 记录这段历史口径。已发布的 Release 正文保持原样。 - Make the Windows CI leg non-blocking while keeping it running on every event (#316): split `validate` into blocking `validate-posix` (Linux x64, macOS arm64) and `continue-on-error` `validate-windows`, and keep `ci-required` from depending on any `continue-on-error` job, so the aggregate neither waits for nor fails with the slowest leg. On `main` after #307 the Windows leg takes 527 / 529 s — up from 482 s before #307, because `npm ci` now downloads the 411 MB Windows Bash payload — against 136–139 s (Linux) and 168–182 s (macOS). `scripts/check-workflows.mjs` now enforces "every blocking job in the aggregate, no non-blocking job in it" and that the aggregate's failure loop names exactly the blocking jobs, so a job cannot stay in `needs` but escape judgement; the Windows result is a signal to act on, not a merge gate. - 将 CI 的 Windows 腿改为非阻塞、但仍每次触发都运行(#316):把 `validate` 拆成阻塞的 `validate-posix`(Linux x64、macOS arm64)与 `continue-on-error` 的 `validate-windows`,并让 `ci-required` 不依赖任何 `continue-on-error` job,聚合既不等待也不随最慢的一腿失败。`main` 在 #307 合入后实测 Windows 腿 527 / 529 秒(#307 之前 482 秒,因为 `npm ci` 现在要拉 411 MB 的 Windows Bash 载荷),Linux 136–139 秒、macOS 168–182 秒。`scripts/check-workflows.mjs` 现在强制“每个阻塞 job 都在聚合里、聚合里没有非阻塞 job”,并要求失败判定循环里的 job 集合与阻塞集合完全相等,避免 job 留在 `needs` 却逃过判定;Windows 结果是待处理的信号,不是合并门禁。 - Bound each `ops_replace` output to the same 32 MiB UTF-8 byte ceiling as file writes. Check expansion with overflow-safe arithmetic for the entire plan before constructing replacement content or publishing any file, including dry runs; an over-limit target returns `OUTPUT_LIMIT` with no file changes (#330). diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index cc3bf8c4..40d7ca67 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -72,6 +72,14 @@ payload dependency (see the 0.2.1 historical limitation below). All three workers are staged with version/target/hash receipts. Formal release closure requires every exact target and the Windows IPC binary/source closure. Local `check --staged` is a development check, not release approval. Final verification is linked in `docs/release-0.3.1.md` and the GitHub Release; early contributor measurements are not substituted for the integrated revision. GitHub publication does not automatically publish npm. +The 0.3.1 Release notes headed that asset table `Target | SHA-256` and listed the +**worker** digest — the value `runtime//provenance.json` records — not the +digest of the attached `native--runtime-v0.3.1.zip`; the 0.3.2 notes kept +the same heading and listed the **ZIP** digest. Both values are meaningful and +they cover different bytes, so comparing one against the other reports a false +mismatch. The published bodies are left as they were; this paragraph is the +correction, recorded here the same way as the notes-only Releases below. + ## Historical development evidence The file component is no longer a FastCtx client. `native/src/file.rs` implements diff --git a/docs/release-process.md b/docs/release-process.md index 6d761f1e..4957fac9 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -2,6 +2,8 @@ > 0.4.0 仍是[未发布候选](0.4.0发布候选.md),验收采用已批准的 OPS 0.3.x BM-01 基线与文件/发现 BM-03 预算,最终结果须单独核验。下面的仓库工作流状态是历史记录,实际发布前须重新检查;本次更新不启用发布、不打 tag、不发布包。 > The [0.4.0 candidate](release-0.4.0.md) uses the approved OPS 0.3.x BM-01 comparator and file/discovery BM-03 budget; verify final results separately. Workflow state below is historical and must be checked before any release; this update does not enable publishing, tag or publish packages. +> 指纹口径:外部产物的字节以 `SHA256SUMS` 为准;Release 正文(notes)不是摘要的权威,本工作流发布时抄进 run summary 的只是它的副本。由维护者本机发布的旧版本没有 run summary,也不一定带 `SHA256SUMS` 资产;它们的指纹以包内 `runtime//provenance.json` 为准(历史正文里的数值,见 `docs/PROVENANCE.md` 的更正记录)。任何公布的指纹都必须写明对象(哪一个文件的指纹),不得只手抄数值。 +> Checksum scope: the bytes of every external artifact are authoritative in `SHA256SUMS`; the Release body (notes) never defines a checksum, and the run summary is only a copy of that manifest. Releases published from a maintainer machine have no run summary and did not always carry a `SHA256SUMS` asset; for those, the in-package `runtime//provenance.json` is the record (see the correction in `docs/PROVENANCE.md`). Every published checksum must name its object, never a bare number. 对象是 `.github/workflows/release.yml`(workflow 名 `Release`)。发布目标只有一个:**GitHub Release**(tag + 自动 notes + 一个可安装主包 tarball + `SHA256SUMS`)。npm publish 不在这条流水线里,理由见下。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4ba60e89..4408d26e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -46,6 +46,20 @@ The provisioned copy lives in `/dsh-ops/shells` (shared across profile When the bundled bash payload remains declared in the profile lockfile but its package directory has been removed by the host package manager, `ops_bash` returns a readable `ENOENT` diagnostic instead of exposing a raw `spawn` exception to the model. Restore the dependency with the profile's frozen install or official plugin manager; it does not silently switch to an unauthorized system bash. +### How do I verify what I downloaded from a Release + +Two checksums are involved and they cover different bytes: `SHA256SUMS` lists the released tarballs, while each tarball records the digest of the native worker it contains in `runtime//provenance.json`. Neither value is defined by the Release body. + +The tarball ships its own checker, so no platform-specific hashing tool is needed: + +```console +tar -xzf dsh-ops-.tgz +node package/bin/native-runtime.mjs check --root package --platform win32-x64 +# win32-x64: bytes, sha256 +``` + +That digest is the one `package/runtime//provenance.json` holds. Run `tar` from inside the download directory with the bare file name: Git for Windows ships GNU tar, which reads a `C:\...` argument as a remote host. `SHA256SUMS` covers every tarball of that release — including the Windows Bash payload — so `sha256sum -c SHA256SUMS` only passes when all of them sit side by side; macOS has no `sha256sum` and uses `shasum -a 256 -c SHA256SUMS`. + --- ## Command execution diff --git "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" index 2873ce07..9a343018 100644 --- "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" @@ -46,6 +46,20 @@ provisioned 副本在 `/dsh-ops/shells`(跨 profile 共享,provisi 当随包 bash 载荷在 profile 的声明/lockfile 中存在、但实际 `node_modules` 已被宿主包管理器清理时,`ops_bash` 会返回可读的 `ENOENT` 诊断,而不是把裸的 `spawn` 异常抛给模型。此时应使用 profile 的 frozen install 或官方插件管理器恢复依赖;不会静默切换到未授权的系统 bash。 +### 怎么验证从 Release 下载的东西 + +这里有两个摘要,覆盖的字节不同:`SHA256SUMS` 列的是本次发布的各个 tarball,而每个 tarball 在 `runtime//provenance.json` 里记录自带 native worker 的摘要。两者都不由 Release 正文定义。 + +包里自带校验器,不需要依赖平台专有的哈希工具: + +```console +tar -xzf dsh-ops-.tgz +node package/bin/native-runtime.mjs check --root package --platform win32-x64 +# win32-x64: bytes, sha256 +``` + +打印出来的就是 `package/runtime//provenance.json` 里记录的值。请在下载目录里用裸文件名运行 `tar`:Git for Windows 带的是 GNU tar,会把 `C:\...` 参数当成远程主机。`SHA256SUMS` 覆盖该 release 的全部 tarball(含 Windows Bash 载荷),所以在同级目录跑 `sha256sum -c SHA256SUMS` 需要把文件都下齐;macOS 没有 `sha256sum`,用 `shasum -a 256 -c SHA256SUMS`。 + --- ## 命令执行 diff --git a/test/docs-facts.test.mjs b/test/docs-facts.test.mjs index 229da2b6..3aabf918 100644 --- a/test/docs-facts.test.mjs +++ b/test/docs-facts.test.mjs @@ -51,4 +51,21 @@ await test('the discovery allowedTools example is exactly the discoverable catal } }) +// #300: a published checksum without its object is not a contract — the notes said +// `SHA-256` and meant the worker in one release, the archive in the next. The docs now +// have to name both objects, and the checker they recommend has to be a real script. +await test('docs name the object of each published checksum and keep the checker', () => { + const releaseProcess = read('docs/release-process.md') + // Both clauses are unique to the stated contract: `SHA256SUMS` already appears as a + // build artifact elsewhere, so matching the word alone would stay green on a revert. + assert.match(releaseProcess, /authoritative in `SHA256SUMS`/u) + assert.match(releaseProcess, /never defines a checksum/u) + assert.doesNotMatch(releaseProcess, /正文为准/u) + for (const file of ['docs/troubleshooting.md', 'docs/常见问题.md']) { + assert.match(read(file), /native-runtime\.mjs check --root package --platform/u, `${file} lost the in-package checker`) + } + assert.equal(fs.existsSync(path.join(PACKAGE_ROOT, 'bin/native-runtime.mjs')), true, + 'the checker the docs recommend no longer exists') +}) + report('docs-facts') From 5649b39b6b3e23af5759e8f5932a10e76e0d10e4 Mon Sep 17 00:00:00 2001 From: ikliml666 Date: Thu, 22 Oct 2026 12:31:06 +0800 Subject: [PATCH 04/50] fix(ptc): re-probe only the agents a change touches PTC and discovery assembled the whole prompt once per tracked agent on every reconcile, so creating one session paid for every session still tracked. 38 bundles on Windows x64 grew 264/308/396/475/590/681/748/947 ms across eight session/create calls; with this change the same eight rounds measure 195/226/270/260/295/329/358/373 ms. Each agent now memoizes its presentation verdict against the tool names it publishes and re-probes only when that set changes. reconcile() takes the agents a change actually touches, and the publish-time snapshot reads names instead of rebuilding a scope view per tool. Refs #333, #359 --- docs/CHANGELOG.md | 3 ++ src/discovery/index.ts | 20 ++++++++++- src/ptc/index.ts | 82 +++++++++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0ec16497..3efa953e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Stop re-probing every tracked agent on each PTC publish. `ptc` and `discovery` both assembled the entire prompt once per agent per reconcile, so a new session paid for every session still tracked: on Windows x64 with 38 bundles the same `session/create` took 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms over eight rounds. Each agent now memoizes its presentation verdict against the tool names it publishes and re-probes only when that set changes, `reconcile` accepts the agents a change actually touches, and the per-publish tool snapshot reads names instead of rebuilding a scope view per tool. The same eight rounds now measure 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms (#333, #359). +- 不再在每次 PTC 发布时重新探测全部已跟踪 agent。`ptc` 与 `discovery` 都在每次 reconcile 里对每个 agent 完整装配一次提示词,因此新建一个会话要付出所有仍在跟踪的会话的代价:Windows x64、38 个 bundle 下,同一个 `session/create` 八轮为 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms。现在每个 agent 按自己发布的工具名缓存呈现判定、只有该集合变化时才重新探测,`reconcile` 接受「本次真正受影响的 agent」,每次发布的工具快照只读工具名而不再为每个工具重建一次 scope 视图。同样八轮实测为 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms(#333、#359)。 + - Make the Windows CI leg non-blocking while keeping it running on every event (#316): split `validate` into blocking `validate-posix` (Linux x64, macOS arm64) and `continue-on-error` `validate-windows`, and keep `ci-required` from depending on any `continue-on-error` job, so the aggregate neither waits for nor fails with the slowest leg. On `main` after #307 the Windows leg takes 527 / 529 s — up from 482 s before #307, because `npm ci` now downloads the 411 MB Windows Bash payload — against 136–139 s (Linux) and 168–182 s (macOS). `scripts/check-workflows.mjs` now enforces "every blocking job in the aggregate, no non-blocking job in it" and that the aggregate's failure loop names exactly the blocking jobs, so a job cannot stay in `needs` but escape judgement; the Windows result is a signal to act on, not a merge gate. - 将 CI 的 Windows 腿改为非阻塞、但仍每次触发都运行(#316):把 `validate` 拆成阻塞的 `validate-posix`(Linux x64、macOS arm64)与 `continue-on-error` 的 `validate-windows`,并让 `ci-required` 不依赖任何 `continue-on-error` job,聚合既不等待也不随最慢的一腿失败。`main` 在 #307 合入后实测 Windows 腿 527 / 529 秒(#307 之前 482 秒,因为 `npm ci` 现在要拉 411 MB 的 Windows Bash 载荷),Linux 136–139 秒、macOS 168–182 秒。`scripts/check-workflows.mjs` 现在强制“每个阻塞 job 都在聚合里、聚合里没有非阻塞 job”,并要求失败判定循环里的 job 集合与阻塞集合完全相等,避免 job 留在 `needs` 却逃过判定;Windows 结果是待处理的信号,不是合并门禁。 - Bound each `ops_replace` output to the same 32 MiB UTF-8 byte ceiling as file writes. Check expansion with overflow-safe arithmetic for the entire plan before constructing replacement content or publishing any file, including dry runs; an over-limit target returns `OUTPUT_LIMIT` with no file changes (#330). diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 678fb2fd..d6394af4 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -69,6 +69,24 @@ export function apply(root: Context, raw: unknown): void { } const ptcOnly = (assembly: PromptAssembly): boolean => assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) const native = (agent: Agent): Promise => isNativePresentation(prompt, agent) + /** + * Probe result memo keyed by the agent's visible tool names. + * + * A presentation probe assembles the whole prompt, so probing every tracked agent on + * every reconcile made one session's tool change cost more as sessions accumulated. + * The verdict only depends on which tools the scope publishes; a different set of names + * re-probes, and an agent nobody references drops out with its entry. + */ + const probed = new WeakMap() + const surfaceOf = (agent: Agent): string => tools.schemas(agent).map(schema => schema.name).join('\u0000') + const settledNative = async (agent: Agent): Promise => { + const surface = surfaceOf(agent) + const memo = probed.get(agent) + if (memo !== undefined && memo.surface === surface) return memo.native + const verdict = await native(agent).catch(() => false) + probed.set(agent, { surface, native: verdict }) + return verdict + } const own = async (agent: Agent, entry: Entry, tool: ToolDefinition): Promise => { if (!entry.ctx) return const execute = tool.execute @@ -92,7 +110,7 @@ export function apply(root: Context, raw: unknown): void { chain = chain.then(async () => { if (stopped || version !== epoch) return const ready: [Agent, Entry][] = [] - for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && permitted(agent) && await native(agent).catch(() => false)) ready.push([agent, entry]) + for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && permitted(agent) && await settledNative(agent)) ready.push([agent, entry]) if (stopped || version !== epoch) return mutating = true; ownership?.begin() try { diff --git a/src/ptc/index.ts b/src/ptc/index.ts index 66df24c0..4d476156 100644 --- a/src/ptc/index.ts +++ b/src/ptc/index.ts @@ -27,6 +27,8 @@ const ptcDefinitions = new WeakSet() interface Entry { ctx?: Context; fiber?: Fiber; ready: Promise; disposers: (() => void)[] selected: Set; pending: Set; owned: Map; ptc: boolean + /** Tool names this agent published when `ptc` was last probed; a change re-probes. */ + surface?: string } const isPtc = (assembly: PromptAssembly): boolean => assembly.tools.length === 1 && assembly.tools[0]?.name === 'run_code' && assembly.sections.some(section => section.name === 'tools:ptc-only' && section.text.trim() !== '') @@ -45,19 +47,22 @@ export function apply(root: Context, rawConfig: unknown): void { mountResultLifecycle(ctx) const entries = new Map() const active = new Map }>() - let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve(), lastSnapshot = '' + let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() let settled = chain const drain = async () => { let current: Promise do { current = chain; await current } while (current !== chain) settled = current } - const ids = new WeakMap(); let sequence = 0 - const snapshot = (): string => JSON.stringify([...entries.keys()].map(agent => tools.schemas(agent).map(schema => { - const def = tools.get(schema.name, agent) - if (def && !ids.has(def)) ids.set(def, ++sequence) - return [schema.name, def && ids.get(def)] - }))) + /** + * Visible tool names of one agent, the surface a `ptcOnly` probe reads. + * + * Names alone decide the probe: `isPtc` only asks whether `run_code` is the sole + * tool, so a definition swapped for an equal name cannot flip the verdict. Reading + * definitions too would cost one `tools.get` per name, and each of those rebuilds the + * whole scope view. + */ + const surfaceOf = (agent: Agent): string => tools.schemas(agent).map(schema => schema.name).join('\u0000') const clear = (entry: Entry) => { entry.owned.clear() for (const dispose of entry.disposers.splice(0)) { @@ -90,15 +95,44 @@ export function apply(root: Context, rawConfig: unknown): void { try { return await work } finally { clearTimeout(timer); active.delete(controller); exec.signal?.removeEventListener('abort', abort) } } - const reconcile = (force = false): Promise => { + /** + * Republish the PTC surface. + * + * `targets` narrows only what is *probed*: each agent's `ptcOnly` answer depends on that + * agent's own tools and assembly, so an agent outside the set keeps the `ptc` it already + * has. Publication itself always covers every entry, because the Native snapshot below is + * exact only once every agent's masks are lifted — a partial pass would read the remaining + * agents through their live overrides and register a short shadow set. + */ + const reconcile = (force = false, targets?: ReadonlySet): Promise => { if (stopped || mutating) return chain + const selected = targets === undefined || targets.size === 0 ? undefined : targets + const probed = (agent: Agent) => selected === undefined || selected.has(agent) + // A probe-free pass (agent disposal) still owes the republish: the remaining agents' + // masks and shadows were computed against the departee's registrations. + const republishAnyway = targets !== undefined && targets.size === 0 const version = ++epoch chain = chain.then(async () => { if (stopped || version !== epoch) return const ready = new Set() - for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && await ptcOnly(agent).catch(() => false)) ready.add(agent) + // A republish re-registers every agent's definitions. It is only owed when some + // agent's published surface or PTC verdict actually moved; `force` asks for a + // fresh probe of the selected agents, not for an unconditional rewrite. + let dirty = false + for (const [agent, entry] of entries) { + if (stopped || version !== epoch) return + if (!entry.ctx || !ownership?.owns(agent)) continue + if (!probed(agent)) { if (entry.ptc) ready.add(agent); continue } + const surface = surfaceOf(agent) + if (!force && entry.surface === surface) { if (entry.ptc) ready.add(agent); continue } + if (entry.surface !== surface) dirty = true + entry.surface = surface + const next = await ptcOnly(agent).catch(() => false) + if (next !== entry.ptc) dirty = true + if (next) ready.add(agent) + } if (stopped || version !== epoch) return - if (!force && snapshot() === lastSnapshot && [...entries].every(([agent, entry]) => entry.ptc === ready.has(agent))) return + if (!dirty && !force && !republishAnyway) return mutating = true; ownership?.begin() try { for (const entry of entries.values()) clear(entry) @@ -140,7 +174,6 @@ export function apply(root: Context, rawConfig: unknown): void { }) if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) } - lastSnapshot = snapshot() } catch (error) { for (const entry of entries.values()) clear(entry) throw error @@ -154,7 +187,7 @@ export function apply(root: Context, rawConfig: unknown): void { const entry: Entry = { disposers: [], selected: new Set(), pending: new Set(), owned: new Map(), ptc: false, ready: Promise.resolve() } entries.set(agent, entry); ownership?.track(agent) entry.fiber = agent.ctx.plugin({ inject: ['tools'], apply(scoped) { entry.ctx = scoped } }) - entry.ready = Promise.resolve(entry.fiber).then(() => reconcile(true)) + entry.ready = Promise.resolve(entry.fiber).then(() => reconcile(true, new Set([agent]))) return entry.ready } ctx.on('agent/created', async ({ agent }) => { await attach(agent); return undefined }) @@ -164,7 +197,8 @@ export function apply(root: Context, rawConfig: unknown): void { if (entry && !signal.aborted && await ptcOnly(agent, signal).catch(() => false)) { for (const tool of entry.pending) entry.selected.add(tool) const changed = entry.pending.size > 0; entry.pending.clear() - await reconcile(changed) + if (changed) entry.surface = undefined + await reconcile(changed, new Set([agent])) } return next() }) @@ -172,9 +206,12 @@ export function apply(root: Context, rawConfig: unknown): void { const entry = entries.get(agent); if (!entry) return entries.delete(agent); ownership?.forget(agent); abortOwner(agent, 'Owning agent disposed'); mutating = true try { clear(entry) } finally { mutating = false } - await entry.fiber?.dispose(); await reconcile(true) + // Other agents' published surfaces are untouched here, so republish none of them. + await entry.fiber?.dispose(); await reconcile(true, new Set()) }) - ctx.on('tools/change', () => { if (!ownership?.publishing()) void reconcile(true) }) + // A registry change only owes a republish when some agent's probed surface moved, + // so the change listener probes and lets the dirty check decide. + ctx.on('tools/change', () => { if (!ownership?.publishing()) void reconcile(false) }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { // The pinned host assembles BEFORE agent/pre-step. Wait for publication // queued by the previous committed step, then obtain a fresh public assembly. @@ -187,24 +224,27 @@ export function apply(root: Context, rawConfig: unknown): void { const result = await next() if (!entry || !heldPtc || isPtc(result)) return result mutating = true - try { entry.ptc = false; entry.pending.clear(); abortOwner(context.scope as Agent, 'PTC presentation withdrawn'); clear(entry) } + try { entry.ptc = false; entry.surface = undefined; entry.pending.clear(); abortOwner(context.scope as Agent, 'PTC presentation withdrawn'); clear(entry) } finally { mutating = false } - void reconcile(true) + void reconcile(true, new Set([context.scope as Agent])) return prompt.assemble(context) }) ctx.on('session/event', (session, event) => { if (event.type === 'step/end') { let changed = false + const affected = new Set() for (const [agent, entry] of entries) if (agent.session === session && ownership?.owns(agent) && entry.pending.size) { for (const tool of entry.pending) if (entry.ptc) entry.selected.add(tool) - entry.pending.clear(); changed = true + entry.pending.clear(); entry.surface = undefined; affected.add(agent); changed = true } - if (changed) void reconcile(true) + if (changed) void reconcile(true, affected) return } if (event.type !== 'sandbox/mode') return - for (const [agent, entry] of entries) if (agent.session === session) { entry.pending.clear(); abortOwner(agent, 'Sandbox authority changed') } - void reconcile(true) + const affected = new Set() + for (const [agent, entry] of entries) if (agent.session === session) { entry.pending.clear(); entry.surface = undefined; abortOwner(agent, 'Sandbox authority changed'); affected.add(agent) } + // An unknown session keeps the unqualified (whole-fleet) pass. + void reconcile(true, affected.size ? affected : undefined) }) ownership = await ownPtcScopes(ctx, () => { mutating = true From aa78b54f3a078847ee29b6407e2f4e68a5a598c0 Mon Sep 17 00:00:00 2001 From: OldLigant Date: Thu, 22 Oct 2026 18:58:28 +0800 Subject: [PATCH 05/50] feat(ptc): support macOS Intel (darwin-x64) Accept darwin-x64 in the runtime resolver and profile commands, declare it in every component target row, and add the macOS 15 Intel leg to CI and release. macOS 12 lacks CLONE_ACL (xnu-8020); that older-kernel fallback is a separate follow-up. macOS 13+ shares the existing macOS path. --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 12 +++++++++--- dsh-plugin.json | 10 +++++----- scripts/validate-manifest.mjs | 4 ++-- src/profile-install.ts | 2 +- src/ptc/runtime.ts | 4 ++-- test/linux-scope.test.mjs | 8 ++++---- test/release-scope.test.mjs | 16 ++++++++-------- 8 files changed, 32 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 016df8d4..a6408620 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: include: - { runner: ubuntu-24.04, target: linux-x64 } - { runner: macos-latest, target: darwin-arm64 } + - { runner: macos-15-intel, target: darwin-x64 } runs-on: ${{ matrix.runner }} timeout-minutes: 35 env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92c486af..81787446 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,8 @@ name: Release # checksum manifest (docs/PROVENANCE.md), not a second, unmaintained mechanism, and an # attestation would need `id-token: write` / `attestations: write` on the repository. # -# SCOPE: exactly the three declared targets in lib/ptc/runtime.js — win32-x64, -# linux-x64, darwin-arm64. Intel macOS, Windows arm64 and Linux arm64 are outside the +# SCOPE: exactly the four declared targets in lib/ptc/runtime.js — win32-x64, +# linux-x64, darwin-arm64 and darwin-x64. Windows arm64 and Linux arm64 are outside the # matrix; `bin/native-runtime.mjs` refuses them, and `bin/check-release.mjs` fails the # run if any other target appears under `runtime/`. on: @@ -57,6 +57,7 @@ jobs: - { runner: windows-latest, target: win32-x64, triple: x86_64-pc-windows-msvc } - { runner: ubuntu-24.04, target: linux-x64, triple: x86_64-unknown-linux-gnu } - { runner: macos-latest, target: darwin-arm64, triple: aarch64-apple-darwin } + - { runner: macos-15-intel, target: darwin-x64, triple: x86_64-apple-darwin } runs-on: ${{ matrix.runner }} timeout-minutes: 45 env: @@ -166,6 +167,11 @@ jobs: name: native-darwin-arm64 path: runtime/darwin-arm64 + - uses: actions/download-artifact@v8 + with: + name: native-darwin-x64 + path: runtime/darwin-x64 + - name: Prepare the host worker for functional suites # Boundary suites intentionally resolve their checkout-local host build. # This build is test-only; the staged release runtimes remain the cold matrix artifacts. @@ -184,7 +190,7 @@ jobs: # Publishing must not start before this step is green — it is the only reason # the `publish` job sits behind `needs`. `verify:release` is # `validate-manifest` + the full repository suite + `bin/check-release.mjs`, - # which demands all three declared targets, matches every receipt hash against + # which demands all four declared targets, matches every receipt hash against # the staged bytes, and re-checks the Windows IPC binary and source digests. # A partial download, a foreign-architecture binary, a stale receipt or a # manifest/target disagreement fails here instead of shipping. diff --git a/dsh-plugin.json b/dsh-plugin.json index f88c1649..0da9d40d 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -109,7 +109,7 @@ "defaultEnabled": true, "platforms": ["win32", "linux", "darwin"], "architectures": ["x64", "arm64"], - "targets": ["win32-x64", "linux-x64", "darwin-arm64"], + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"], "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_files","ops_read_table","ops_read_docx","ops_read_pdf","ops_batch","ops_result","ops_load_sdk"], @@ -120,21 +120,21 @@ "entry": "dsh-ops/shell", "defaultEnabled": true, "platforms": ["win32", "linux", "darwin"], - "targets": ["win32-x64", "linux-x64", "darwin-arm64"] + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"] }, { "id": "dsh-ops-file", "entry": "dsh-ops/file", "defaultEnabled": true, "platforms": ["win32", "linux", "darwin"], - "targets": ["win32-x64", "linux-x64", "darwin-arm64"] + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"] }, { "id": "dsh-ops-discovery", "entry": "dsh-ops/discovery", "defaultEnabled": true, "platforms": ["win32", "linux", "darwin"], - "targets": ["win32-x64", "linux-x64", "darwin-arm64"], + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"], "presentation": "Native scopes; activation on the next admitted model step", "tools": ["ops_discover_tools"] }, @@ -144,7 +144,7 @@ "defaultEnabled": false, "presentation": "Disabled by default; enabling the row captures subsequent events. Explicit legacy capture:false stays off until changed.", "platforms": ["win32", "linux", "darwin"], - "targets": ["win32-x64", "linux-x64", "darwin-arm64"], + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"], "tools": [] }, { diff --git a/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs index 0dee414f..3dfeca10 100644 --- a/scripts/validate-manifest.mjs +++ b/scripts/validate-manifest.mjs @@ -490,8 +490,8 @@ check(JSON.stringify(ptc?.tools) === JSON.stringify([...PTC_API_TOOLS, 'load_sdk check(PTC_API_TOOLS.every(t => definition(t, () => {}).parameters.type === 'object'), 'PTC schemas remain structured') check(JSON.stringify(pkg.os) === '["win32","linux","darwin"]' && JSON.stringify(pkg.cpu) === '["x64","arm64"]' && JSON.stringify(ptc?.platforms) === '["win32","linux","darwin"]' && JSON.stringify(ptc?.architectures) === '["x64","arm64"]' && - JSON.stringify(ptc?.targets) === '["win32-x64","linux-x64","darwin-arm64"]', - 'Supported exact targets are Windows x64, Linux x64 and macOS Apple Silicon; Intel Mac is unsupported') + JSON.stringify(ptc?.targets) === '["win32-x64","linux-x64","darwin-arm64","darwin-x64"]', + 'Supported exact targets are Windows x64, Linux x64 and macOS arm64/x64; Intel Mac is supported') /** Every component states the platforms it really serves, so a Linux claim cannot ride along. */ const platformClaims = id => manifestRaw.contributes?.['x-components']?.find(c => c.id === id)?.platforms check(JSON.stringify(platformClaims('dsh-ops-shell')) === '["win32","linux","darwin"]', diff --git a/src/profile-install.ts b/src/profile-install.ts index 003bbb75..1fef155f 100644 --- a/src/profile-install.ts +++ b/src/profile-install.ts @@ -15,7 +15,7 @@ import { PLATFORMS } from './ptc/runtime.js' * the native payloads are a separate question with their own gate in * `lib/ptc/runtime.js`. */ -const SUPPORTED_TARGETS: Readonly>> = Object.freeze({ win32: ['x64'], linux: ['x64'], darwin: ['arm64'] }) +const SUPPORTED_TARGETS: Readonly>> = Object.freeze({ win32: ['x64'], linux: ['x64'], darwin: ['arm64', 'x64'] }) /** * Where each platform's desktop application keeps its own CLI. diff --git a/src/ptc/runtime.ts b/src/ptc/runtime.ts index 1b72395c..102069ff 100644 --- a/src/ptc/runtime.ts +++ b/src/ptc/runtime.ts @@ -7,7 +7,7 @@ export interface WorkerSelection { binaryPath?: string } export interface RuntimeOptions { packageRoot?: string; env?: NodeJS.ProcessEnv; platform?: string; arch?: string } export type WorkerResolution = { ok: true; file: string } | { ok: false; reason: string } export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') -export const PLATFORMS = Object.freeze(['win32-x64', 'linux-x64', 'darwin-arm64']) +export const PLATFORMS = Object.freeze(['win32-x64', 'linux-x64', 'darwin-arm64', 'darwin-x64']) export function workerName(platform: string = process.platform) { return platform === 'win32' ? 'dsh-ops-worker.exe' : 'dsh-ops-worker' } /** * Read a JSON file, or name it. @@ -60,7 +60,7 @@ export function tryResolveWorker(config: WorkerSelection = {}, options: RuntimeO } export function resolveWorker(config: WorkerSelection = {}, { packageRoot = PACKAGE_ROOT, env = process.env, platform = process.platform, arch = process.arch }: RuntimeOptions = {}): string { - if (!PLATFORMS.includes(`${platform}-${arch}`)) throw new Error(`dsh-ops supports Windows x64, Linux x64 and macOS Apple Silicon (darwin-arm64) only; unsupported runtime target ${platform}-${arch}.`) + if (!PLATFORMS.includes(`${platform}-${arch}`)) throw new Error(`dsh-ops supports Windows x64, Linux x64 and macOS (arm64/x64) only; unsupported runtime target ${platform}-${arch}.`) const explicit = config.binaryPath ?? env.DSH_OPS_WORKER_BIN const fromPackage = path.resolve(packageRoot, 'runtime', `${platform}-${arch}`, workerName(platform)) const fromSourceTree = path.resolve(packageRoot, 'native', 'target', 'release', workerName(platform)) diff --git a/test/linux-scope.test.mjs b/test/linux-scope.test.mjs index 1c37410a..adaf334f 100644 --- a/test/linux-scope.test.mjs +++ b/test/linux-scope.test.mjs @@ -88,7 +88,7 @@ function stageRuntime(root, { target, bytes = Buffer.from('synthetic worker byte } await test('every foreign target is refused by name, naming the supported set', async () => { - for (const target of ['darwin-x64', 'linux-arm64', 'win32-arm64', 'linux-riscv64', 'openharmony-arm64']) { + for (const target of ['linux-arm64', 'win32-arm64', 'linux-riscv64', 'openharmony-arm64', 'darwin-riscv64']) { const [platform, arch] = target.split('-') await withTempDir(`linux-scope-${target}`, async root => { let error = null @@ -96,11 +96,11 @@ await test('every foreign target is refused by name, naming the supported set', try { resolved = resolveWorker({}, { packageRoot: stagePackage(root), platform, arch, env: {} }) } catch (caught) { error = caught } assert.equal(resolved, null, `${target} resolved to ${resolved}`) assert.ok(error, `${target} was allowed`) - assert.match(error.message, /Windows x64, Linux x64 and macOS Apple Silicon/) + assert.match(error.message, /Windows x64, Linux x64 and macOS/) assert.ok(error.message.includes(target), `the message must name ${target}: ${error.message}`) }) } - assert.deepEqual([...PLATFORMS], ['win32-x64', 'linux-x64', 'darwin-arm64']) + assert.deepEqual([...PLATFORMS], ['win32-x64', 'linux-x64', 'darwin-arm64', 'darwin-x64']) assert.equal(workerName('win32'), 'dsh-ops-worker.exe') assert.equal(workerName('linux'), 'dsh-ops-worker') }) @@ -150,7 +150,7 @@ await test('package.json, the resolver and the plugin manifest declare the same const components = PLUGIN.contributes?.['x-components'] ?? [] const ptc = components.find(component => component.id === 'dsh-ops-ptc') assert.ok(ptc, 'the PTC component row is missing from contributes.x-components') - assert.deepEqual([...PLATFORMS], ['win32-x64', 'linux-x64', 'darwin-arm64'], 'only the approved target pairs are supported') + assert.deepEqual([...PLATFORMS], ['win32-x64', 'linux-x64', 'darwin-arm64', 'darwin-x64'], 'only the approved target pairs are supported') assert.deepEqual(manifest.os, ['win32', 'linux', 'darwin']) assert.deepEqual(ptc.platforms, ['win32', 'linux', 'darwin']) assert.deepEqual(ptc.architectures, ['x64', 'arm64']) diff --git a/test/release-scope.test.mjs b/test/release-scope.test.mjs index c4945658..5c8f58f9 100644 --- a/test/release-scope.test.mjs +++ b/test/release-scope.test.mjs @@ -10,7 +10,7 @@ function header(target) { const bytes = Buffer.alloc(80) if (target === 'win32-x64') { bytes.write('MZ'); bytes.writeUInt32LE(64,60); bytes.writeUInt32LE(0x4550,64); bytes.writeUInt16LE(0x8664,68) } if (target === 'linux-x64') { Buffer.from([127,69,76,70,2,1]).copy(bytes); bytes.writeUInt16LE(62,18) } - if (target === 'darwin-arm64') { bytes.writeUInt32LE(0xfeedfacf,0); bytes.writeUInt32LE(0x100000c,4) } + if (target === 'darwin-arm64' || target === 'darwin-x64') { bytes.writeUInt32LE(0xfeedfacf,0); bytes.writeUInt32LE(target.endsWith('x64') ? 0x1000007 : 0x100000c,4) } return bytes } function fixture(root, targets = PLATFORMS) { @@ -41,15 +41,15 @@ function run(root, name='check-release.mjs', args=[]) { const result=spawnSync(process.execPath,[path.join(root,'bin',name),...(name==='native-runtime.mjs'?['check']:[]),'--root',root,...args],{encoding:'utf8'}) return {status:result.status,out:`${result.stdout}${result.stderr}`} } -await test('all three paired targets and Windows IPC close successfully',async()=>withTempDir('release-all',root=>{ +await test('all four paired targets and Windows IPC close successfully',async()=>withTempDir('release-all',root=>{ fixture(root); const result=run(root); assert.equal(result.status,0,result.out); assert.match(result.out,/Release closure OK/) })) await test('each missing target fails strict release while staged source checks remain local',async()=>withTempDir('release-missing',root=>{ fixture(root,['linux-x64']); assert.equal(run(root,'native-runtime.mjs',['--staged']).status,0) - const result=run(root); assert.notEqual(result.status,0); assert.match(result.out,/missing win32-x64, darwin-arm64/) + const result=run(root); assert.notEqual(result.status,0); assert.match(result.out,/missing win32-x64, darwin-arm64, darwin-x64/) })) -await test('every foreign target is rejected including Intel Mac',async()=>withTempDir('release-foreign',root=>{ - fixture(root); for(const target of ['darwin-x64','win32-arm64','linux-arm64']) { +await test('every foreign target is rejected',async()=>withTempDir('release-foreign',root=>{ + fixture(root); for(const target of ['win32-arm64','linux-arm64','darwin-riscv64']) { const result=run(root,'native-runtime.mjs',['--platform',target]); assert.notEqual(result.status,0); assert.match(result.out,/Unsupported target/) } })) @@ -62,13 +62,13 @@ await test('Windows worker without IPC cannot pass',async()=>withTempDir('releas const result=run(root); assert.notEqual(result.status,0); assert.match(result.out,/requires its Bash IPC/) })) await test('half-staged and unexpected targets are never silently skipped',async()=>withTempDir('release-stray',root=>{ - fixture(root); fs.mkdirSync(path.join(root,'runtime/darwin-x64')); assert.match(run(root).out,/Unexpected runtime targets/) - fs.rmSync(path.join(root,'runtime/darwin-x64'),{recursive:true}); fs.unlinkSync(path.join(root,'runtime/darwin-arm64/provenance.json')) + fixture(root); fs.mkdirSync(path.join(root,'runtime/darwin-riscv64')); assert.match(run(root).out,/Unexpected runtime targets/) + fs.rmSync(path.join(root,'runtime/darwin-riscv64'),{recursive:true}); fs.unlinkSync(path.join(root,'runtime/darwin-arm64/provenance.json')) assert.match(run(root,'native-runtime.mjs',['--staged']).out,/Half-staged runtime/) })) await test('manifest exact targets, npm platform filters and shell pins must agree',async()=>withTempDir('release-drift',root=>{ fixture(root); const file=path.join(root,'dsh-plugin.json'), manifest=JSON.parse(fs.readFileSync(file,'utf8')) - manifest.contributes['x-components'].find(c=>c.id==='dsh-ops-ptc').targets.push('darwin-x64') + manifest.contributes['x-components'].find(c=>c.id==='dsh-ops-ptc').targets.push('win32-arm64') fs.writeFileSync(file,JSON.stringify(manifest)); assert.match(run(root).out,/disagree/) })) await test('release closure requires the pinned Bash payload and refuses a PowerShell dependency', async () => withTempDir('release-shell-dep', root => { From 62f27a0d87873ee9945c386dacc8c346019eff65 Mon Sep 17 00:00:00 2001 From: OldLigant Date: Thu, 22 Oct 2026 19:08:11 +0800 Subject: [PATCH 06/50] fix(ptc): assert a still-foreign target after darwin-x64 support The installed-package check asserted darwin-x64 must be rejected, which fails on every host once the target is supported. Assert darwin-riscv64 instead; the matrix gate is unchanged. --- test/ptc.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ptc.test.mjs b/test/ptc.test.mjs index 9251099a..af22601c 100644 --- a/test/ptc.test.mjs +++ b/test/ptc.test.mjs @@ -387,7 +387,7 @@ try { fs.cpSync(runtimeDir,path.join(isolatedPackage,'runtime',`${process.platform}-${process.arch}`),{recursive:true}) assert.throws(() => resolveWorker({}, {platform:'linux',arch:'arm64'}), /unsupported runtime target/) assert.throws(() => resolveWorker({}, {platform:'win32',arch:'arm64'}), /unsupported runtime target/) - assert.throws(() => resolveWorker({}, {platform:'darwin',arch:'x64'}), /unsupported runtime target/) + assert.throws(() => resolveWorker({}, {platform:'darwin',arch:'riscv64'}), /unsupported runtime target/) assert.ok(resolveWorker({}, {packageRoot:isolatedPackage}).startsWith(isolatedPackage)) console.log('ok: installed runtime resolves without binaryPath or source build tree') const real = new Context(); real.logger.level = 0 From e99d64bead7fd4fdc898dc7c8e72dbc7c5d05e75 Mon Sep 17 00:00:00 2001 From: OldLigant Date: Thu, 22 Oct 2026 19:35:32 +0800 Subject: [PATCH 07/50] ci: run macOS Intel as a non-blocking signal The Intel leg measured 318 s, over the repo's >300 s line, so keep it out of validate-posix and ci-required. Add a separate continue-on-error job that runs the same gate and uploads native-darwin-x64. --- .github/workflows/ci.yml | 63 +++++++++++++++++-- docs/CONTRIBUTING.md | 10 +-- docs/ci.md | 9 ++- docs/verify-locally.md | 7 ++- ...41\347\214\256\346\214\207\345\215\227.md" | 6 +- scripts/check-workflows.mjs | 2 +- 6 files changed, 77 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6408620..c8f7c222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,8 @@ concurrency: # because a half-built release branch is worse than a queued one. cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # The POSIX legs are the gate: a red Linux or macOS run fails `ci-required` and blocks the merge. - # Windows is split into its own, non-blocking job directly below (#316). + # The blocking POSIX legs are Linux x64 and macOS arm64: a red one fails `ci-required`. + # macOS Intel and Windows are separate non-blocking signals below (#316). validate-posix: strategy: fail-fast: false @@ -46,7 +46,6 @@ jobs: include: - { runner: ubuntu-24.04, target: linux-x64 } - { runner: macos-latest, target: darwin-arm64 } - - { runner: macos-15-intel, target: darwin-x64 } runs-on: ${{ matrix.runner }} timeout-minutes: 35 env: @@ -104,6 +103,56 @@ jobs: if-no-files-found: error retention-days: 7 + # macOS Intel is a signal, not a gate: this leg measured 318 s on the PR run that added + # it, past the approved ">300 s -> non-blocking" line (#316). It still runs on every push, + # pull request and merge group, and uploads the darwin-x64 artifact for the release job. + validate-macos-intel: + runs-on: macos-15-intel + continue-on-error: true + timeout-minutes: 35 + env: + CARGO_BUILD_JOBS: '3' + DSH_OPS_SUITE_TIMEOUT_MS: '300000' + DSH_OPS_RETRY_TIMEOUTS: '1' + steps: + - uses: actions/checkout@v7 + with: + # The provenance regression verifies exact blobs from the pinned 0.3.1 commit. + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - name: Verify runner identity + run: node -e "if(process.platform+'-'+process.arch!=='darwin-x64')throw Error('Unexpected runner'); console.log(process.platform,process.arch)" + - name: Set up pinned Rust + shell: bash + run: rustup toolchain install 1.97.1 --profile minimal && rustup default 1.97.1 + - name: Reuse exact locked Rust build cache + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target + key: ops-031-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/Cargo.lock') }} + restore-keys: ops-031-${{ runner.os }}-${{ runner.arch }}- + - name: Install locked dependencies + run: npm ci --ignore-scripts --registry=https://registry.npmjs.org + - name: Functional gate (skips, suite inventory, packed artifact) + shell: bash + run: node scripts/verify-gate.mjs --skip-install + - name: Verify native staged closure + run: node bin/native-runtime.mjs check --platform darwin-x64 + - name: Check shell payload platform filtering + shell: bash + run: node -e "const fs=require('fs');if(process.platform!=='win32'){for(const n of ['bash','pwsh'])if(fs.existsSync('node_modules/@dsh-ops/'+n+'-win32-x64'))throw Error('Windows shell installed on POSIX')" + - uses: actions/upload-artifact@v7 + with: + name: native-darwin-x64 + path: runtime/darwin-x64/ + if-no-files-found: error + retention-days: 7 + # Windows still runs on every push, pull request and merge group — it is never skipped — but its # result is a **signal, not a gate**. The job is `continue-on-error: true`, and `ci-required` does # not list it in `needs`, so the aggregate neither waits for it nor fails with it. That is what @@ -240,7 +289,8 @@ jobs: # The bench smoke: `workflow_dispatch` only, off by default, and informational (like `tools-trial` # above). One platform is enough, because it exercises the harness rather than the platform — the - # `validate-posix` matrix is the gate and `validate-windows` its non-blocking counterpart. Both + # `validate-posix` matrix is the gate, with `validate-windows` and `validate-macos-intel` as + # non-blocking counterparts. Both # sides are the *same* staged worker, so the run # proves only what a harness self-check can prove: the corpus builds and is removed, the worker # answers identically twice, and every query lands on `MATCH` or a declared `EXPECTED_*`. It cannot @@ -384,7 +434,8 @@ jobs: # names in sync — and silently losing the gate when a runner label changes. This job collapses them. # # It waits **only** on blocking jobs. Every `continue-on-error: true` job — `validate-windows`, - # `tools-trial`, `bench-smoke` — is deliberately absent from `needs` and from the loop below: + # `validate-macos-intel`, `tools-trial`, `bench-smoke` — is deliberately absent from `needs` and + # from the loop below: # listing one here would keep the required check pending until that job finished, which is a gate # in everything but name. `scripts/check-workflows.mjs` enforces both halves of that rule. ci-required: @@ -411,7 +462,7 @@ jobs: done # Not in `needs` on purpose: this check must never wait for them, so their result is not # available here. Read them on their own jobs; a red one is a signal, not a merge blocker. - echo "validate-windows, tools-trial, bench-smoke: non-blocking (continue-on-error), not waited on by this check" + echo "validate-windows, validate-macos-intel, tools-trial, bench-smoke: non-blocking (continue-on-error), not waited on by this check" if [ "$failed" != 0 ]; then echo "::error::a blocking job did not succeed; read the failing job above" exit 1 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index cd823f53..5e0d9ab3 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -114,22 +114,22 @@ What a skip means locally: `test/run.mjs` (every `test/*.test.mjs` in its own process with its own temporary `DSH_HOME`). - `node scripts/verify-gate.mjs --skip-install` — **the release gate**, exactly what - `.github/workflows/ci.yml` runs on Windows x64, Linux x64 and macOS arm64: worker unit tests, build + `.github/workflows/ci.yml` runs on Windows x64, Linux x64, macOS arm64 and macOS Intel x64: worker unit tests, build and stage, manifest floor, every suite, the allowed-skip set, the suite inventory on disk, and the packed artifact. - `node test/run.mjs ` runs only the suites whose file names contain ``, e.g. `node test/run.mjs mount file-operations`. How to read the gate and the expected numbers per host: [verify-locally.md](verify-locally.md). -CI runs on every pull request and every push to `main`, `0.3.2` and `release/0.4.0`. The two POSIX legs (`validate-posix`: Linux x64, macOS arm64) block a merge; the Windows x64 leg (`validate-windows`) still runs on every event but is non-blocking — a red Windows run is a defect to fix, not a reason to hold the merge (#316). +CI runs on every pull request and every push to `main`, `0.3.2` and `release/0.4.0`. The two blocking POSIX legs (`validate-posix`: Linux x64, macOS arm64) block a merge. The Windows x64 leg (`validate-windows`) and the macOS Intel leg (`validate-macos-intel`) still run on every event but are non-blocking — a red run there is a defect to fix, not a reason to hold the merge (#316; the Intel leg measured 318 s). ## One CI run per pull request Every pull request must show one run of the [Cross-platform validation](https://github.com/T-Auto/dsh-ops/blob/main/.github/workflows/ci.yml) workflow that its **own `pull_request` event** triggered. The aggregate job **`CI required`** -summarises the two blocking POSIX legs (Linux x64, macOS arm64); the non-blocking Windows x64 leg -runs alongside it and is deliberately not waited on. That job's run URL is what the pull request -template asks for. +summarises the two blocking POSIX legs (Linux x64, macOS arm64); the non-blocking Windows x64 and +macOS Intel legs run alongside it and are deliberately not waited on. That job's run URL is what the +pull request template asks for. - **What triggers it.** `ci.yml` runs on `pull_request` targeting `main`, `0.3.2` and `release/0.4.0` (and on `push` to the same branches, plus `merge_group`). GitHub reads the diff --git a/docs/ci.md b/docs/ci.md index 31d65a84..0fca470c 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -8,24 +8,27 @@ a reviewer can trust a green PR without re-running anything by hand. | Workflow | Trigger | Blocks a merge? | Purpose | | --- | --- | --- | --- | -| `ci.yml` — **Cross-platform validation** | `push: main`, `pull_request: main`, `merge_group`, manual dispatch | **Yes** (via the single `CI required` check) | The functional gate on Linux and macOS (blocking) and on Windows (a non-blocking signal), plus the Node floor, the source size gate, workflow lint and the tool trial | +| `ci.yml` — **Cross-platform validation** | `push: main`, `pull_request: main`, `merge_group`, manual dispatch | **Yes** (via the single `CI required` check) | The functional gate on Linux and macOS arm64 (blocking), macOS Intel and Windows (non-blocking signals), plus the Node floor, the source size gate, workflow lint and the tool trial | | `sync-master.yml` — **Sync master with main** | `push: main`, manual dispatch | No | Fast-forwards the `master` mirror; fast-forward only, so a divergent `master` fails loudly | -| `release.yml` — **Release** | `push: v*` tags, manual dispatch | No (`disabled_manually` today) | Builds the three declared targets, gates them, and publishes the GitHub Release. Nothing here runs on a branch or a pull request | +| `release.yml` — **Release** | `push: v*` tags, manual dispatch | No (`disabled_manually` today) | Builds the four declared targets, gates them, and publishes the GitHub Release. Nothing here runs on a branch or a pull request | ### Jobs inside `ci.yml` | Job | Runner | Blocking? | What it proves | | --- | --- | --- | --- | | `validate-posix` | `ubuntu-24.04` (linux-x64), `macos-latest` (darwin-arm64) | **Yes** | `cargo test --workspace --locked`, the native worker builds, then `scripts/verify-gate.mjs --skip-install`: no unauthorised skips (`DSH_OPS_REQUIRE_RUNTIME=1` on POSIX turns a missing runtime into a failure), every suite file on disk was collected, and the packed artifact unpacks and passes `native-runtime check` | +| `validate-macos-intel` | `macos-15-intel` (darwin-x64) | **No** (`continue-on-error`) | The same gate on Intel macOS, including the Mach-O x86_64 worker and its receipt; it runs on every push, pull request and merge group and uploads `native-darwin-x64`. The leg measured **318 s**, past the >300 s line (#316), so it is a signal, not a gate (see below) | | `validate-windows` | `windows-latest` (win32-x64) | **No** (`continue-on-error`) | The same gate on Windows, and it still runs on every push, pull request and merge group — the result is a **signal, not a gate**: a red Windows leg is a defect to fix, but it does not hold a merge (see below) | | `node-floor` | `ubuntu-24.04` | **Yes** | `package.json` promises `^22.19 \|\| >=24`; this runs the same gate on the declared **floor** so the promise is exercised rather than asserted | | `workflow-lint` | `ubuntu-24.04` | **Yes** | `scripts/check-workflows.mjs`: every workflow parses, declares triggers/jobs/permissions, bounds each job with `timeout-minutes`, avoids `write-all` and `pull_request_target`, and keeps `ci.yml`'s aggregate check wired to every blocking job — in `needs` and in its failure loop — while keeping every `continue-on-error` job out of both | | `source-size` | `ubuntu-24.04` | **Yes** | `scripts/check-file-size.mjs`: every tracked code file stays at or under 1000 lines. The file set comes from `git ls-files`, so a new module is in scope the moment it is added | | `tools-trial` | `ubuntu-24.04` | **No** (`continue-on-error`) | Boots a real host and calls every published tool; a broken tool shows up in its own job and in the uploaded `tools-trial-report` without blocking unrelated work | -| `ci-required` | `ubuntu-24.04` | — (this is the check to require) | Collapses the matrix into one status: it fails unless every blocking job succeeded, and it waits only on blocking jobs — `validate-windows`, `tools-trial` and `bench-smoke` are `continue-on-error` and are deliberately not in its `needs` | +| `ci-required` | `ubuntu-24.04` | — (this is the check to require) | Collapses the matrix into one status: it fails unless every blocking job succeeded, and it waits only on blocking jobs — `validate-windows`, `validate-macos-intel`, `tools-trial` and `bench-smoke` are `continue-on-error` and are deliberately not in its `needs` | `validate-windows` is non-blocking by measurement, not by preference: on `main` after #307 the leg took **527 / 529 s** — up from 482 s before #307, because `npm ci` now downloads the 411 MB Windows Bash payload — while the blocking legs took 136–139 s (Linux x64) and 168–182 s (macOS arm64). That is well past the approved ">300 s → non-blocking" line (#316); splitting the leg into shards was measured at 1.5–2.4× the runner minutes and still stayed above 300 s on a slow runner. It is still a signal worth reading — a red Windows run is a defect — but the aggregate check does not wait for it, so a merge does not pay for the slowest leg. +`validate-macos-intel` follows the same rule. On the PR run that added the target it took **318 s**, against 174 s for Linux x64 and 146 s for macOS arm64 in the same run. It still runs on every event and uploads `native-darwin-x64`; a red Intel run is a defect to fix, not a reason to hold the merge. The blocking set remains Linux x64 and macOS arm64, plus `node-floor`, `workflow-lint` and `source-size`. + `connectivity` of the required check matters more than its name: require **`CI required`**, not the per-platform names. The blocking matrix expands into one check per runner, so requiring those names by hand means keeping them in sync forever, and losing the gate the day a runner label changes. diff --git a/docs/verify-locally.md b/docs/verify-locally.md index 8162821c..25a57d32 100644 --- a/docs/verify-locally.md +++ b/docs/verify-locally.md @@ -74,11 +74,14 @@ that breaks is visible without blocking a merge. | Host | Suites | Skipped, and why | | --- | --- | --- | -| `linux-x64`, `darwin-arm64` | all of `test/*.test.mjs` | `tui-host` and `tui-native` unless an optional DSH-TUI checkout is present (`DSH_OPS_TUI_ROOT`, plus `DSH_OPS_TUI_WORKER` for the native one); `mount` only when no native worker can be resolved there | +| `linux-x64`, `darwin-arm64`, `darwin-x64` | all of `test/*.test.mjs` | `tui-host` and `tui-native` unless an optional DSH-TUI checkout is present (`DSH_OPS_TUI_ROOT`, plus `DSH_OPS_TUI_WORKER` for the native one); `mount` only when no native worker can be resolved there | | `win32-x64` | same inventory | the two above, plus `file-metadata` and `linux-attributes` (mode bits, POSIX ACLs, user xattrs, inode identity, sparse allocation) and `linux-shell` (the POSIX bash rung, which is its own subject) | `DSH_OPS_REQUIRE_RUNTIME=1` — which turns any skip into a failure — is set on the POSIX hosts -only. On Windows the three suites above skip by platform, and `test/run.mjs` cannot tell that +only. CI runs this same gate on `macos-15-intel` as a non-blocking signal because that leg takes +318 s (>300 s, see [ci.md](ci.md)); locally it is still the full gate. + +On Windows the three suites above skip by platform, and `test/run.mjs` cannot tell that apart from a missing runtime, so the script keeps its protection there through the skip set instead: anything skipped outside that list fails. The suites that would betray a worker that was never built (`permissions`, `ptc`) are not in the list. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 16ba3035..39b476d2 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -83,16 +83,16 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC ## 验证门禁 - `npm run verify` —— 先将 `src/` 构建到 `lib/`,再运行 `scripts/validate-manifest.mjs` 和 `test/run.mjs`(每个 `test/*.test.mjs` 独立进程、独立临时 `DSH_HOME`)。 -- `node scripts/verify-gate.mjs --skip-install` —— **发布门禁**,即 `.github/workflows/ci.yml` 在 Windows x64、Linux x64、macOS arm64 上运行的同一脚本:worker 单元测试、构建与暂存、manifest 下限、全部套件、允许跳过的集合、磁盘上的套件清单,以及打包产物。 +- `node scripts/verify-gate.mjs --skip-install` —— **发布门禁**,即 `.github/workflows/ci.yml` 在 Windows x64、Linux x64、macOS arm64、macOS Intel x64 上运行的同一脚本:worker 单元测试、构建与暂存、manifest 下限、全部套件、允许跳过的集合、磁盘上的套件清单,以及打包产物。 - `node test/run.mjs <过滤词>` 只运行文件名包含该词的套件,例如 `node test/run.mjs mount file-operations`。 -如何阅读门禁输出、各宿主的预期数字:见 [verify-locally.md](verify-locally.md)。CI 在每个 PR 和每次推送到 `main` 时运行。两条 POSIX 腿(`validate-posix`:Linux x64、macOS arm64)阻塞合并;Windows x64 腿(`validate-windows`)仍每次运行但不阻塞合并——它红了要当缺陷处理,只是不挡合并(#316)。 +如何阅读门禁输出、各宿主的预期数字:见 [verify-locally.md](verify-locally.md)。CI 在每个 PR 和每次推送到 `main` 时运行。两条阻塞的 POSIX 腿(`validate-posix`:Linux x64、macOS arm64)阻塞合并;Windows x64 腿(`validate-windows`)与 macOS Intel 腿(`validate-macos-intel`)仍每次运行但不阻塞合并——它们红了要当缺陷处理,只是不挡合并(#316;Intel leg 实测 318 秒)。 ## 每个 PR 必须有一次 pull_request run 每个 PR 都必须有一次由它**自己的 `pull_request` 事件**触发的 [Cross-platform validation](https://github.com/T-Auto/dsh-ops/blob/main/.github/workflows/ci.yml) -运行。聚合 job **`CI required`** 汇总阻塞的 Linux x64 与 macOS arm64 两腿;非阻塞的 Windows x64 腿同时运行,但不在它的等待与判定范围内。PR 模板要求贴出的就是它的 run URL。 +运行。聚合 job **`CI required`** 汇总阻塞的 Linux x64 与 macOS arm64 两腿;非阻塞的 Windows x64 与 macOS Intel 腿同时运行,但不在它的等待与判定范围内。PR 模板要求贴出的就是它的 run URL。 - **触发条件。** `ci.yml` 在 `pull_request` 指向 `main`、`0.3.2` 与 `release/0.4.0` 时触发 (同时对同分支的 `push` 与 `merge_group` 触发)。GitHub 从 PR 自身的 head 读取 workflow 文件, diff --git a/scripts/check-workflows.mjs b/scripts/check-workflows.mjs index 00ffaf93..c654d54f 100644 --- a/scripts/check-workflows.mjs +++ b/scripts/check-workflows.mjs @@ -75,7 +75,7 @@ if (!ci) { check(triggers?.[event]?.branches?.includes('release/0.4.0'), `ci.yml: ${event} includes the 0.4.0 staging branch`) } const jobs = ci.doc.jobs ?? {} - checkPluginBuild('ci.yml', jobs, ['validate-posix', 'validate-windows', 'node-floor', 'tools-trial', 'bench-smoke']) + checkPluginBuild('ci.yml', jobs, ['validate-posix', 'validate-macos-intel', 'validate-windows', 'node-floor', 'tools-trial', 'bench-smoke']) const entry = Object.entries(jobs).find(([, job]) => job?.name === 'CI required') check(entry !== undefined, 'ci.yml: exposes one aggregate check named "CI required"') if (entry) { From db197b3028ebb1d81699c404545978fa9033f505 Mon Sep 17 00:00:00 2001 From: OldLigant Date: Thu, 22 Oct 2026 19:35:34 +0800 Subject: [PATCH 08/50] docs: document macOS Intel support on macOS 13+ Cover darwin-x64 in the macOS guide, usage, troubleshooting, roadmap, release, CI docs and changelog. Keep macOS 12's CLONE_ACL gap explicit as a separate follow-up. --- README.en.md | 3 + README.zh.md | 2 + docs/CHANGELOG.md | 2 + docs/PROVENANCE.md | 6 ++ docs/benchmark-gates.md | 2 +- docs/macos-arm64.md | 42 --------- docs/macos.md | 91 +++++++++++++++++++ docs/macos.zh.md | 82 +++++++++++++++++ docs/native-ptc.md | 15 +-- docs/release-process.md | 12 +-- docs/roadmap.md | 7 +- docs/troubleshooting.md | 18 +++- docs/tui-panel.md | 2 +- docs/tui-panel.zh.md | 2 +- docs/usage.md | 21 +++-- ...77\347\224\250\346\214\207\345\215\227.md" | 18 ++-- ...72\345\207\206\351\227\250\347\246\201.md" | 2 +- ...70\350\247\201\351\227\256\351\242\230.md" | 8 +- .../\350\267\257\347\272\277\345\233\276.md" | 4 +- package.json | 2 +- 20 files changed, 258 insertions(+), 83 deletions(-) delete mode 100644 docs/macos-arm64.md create mode 100644 docs/macos.md create mode 100644 docs/macos.zh.md diff --git a/README.en.md b/README.en.md index 0123cde1..0137ec3d 100644 --- a/README.en.md +++ b/README.en.md @@ -25,6 +25,9 @@ OPS adds bounded file and document tools to DSH. Native mode returns compact, li PTC code receives typed Outcome values from the same one-shot Rust worker. Host integration is strict TypeScript, compiled to the existing JavaScript package paths. +Supported: Windows x64, Linux x64, and macOS 13+ on Apple Silicon or Intel +(`darwin-arm64`, `darwin-x64`). See [macOS](docs/macos.md). + > [!IMPORTANT] > This branch is the **0.4.0 release candidate**, not a published release. Approved acceptance requires > at least 40% less BM-01 output than OPS 0.3.x; the official reader is a separate comparison. diff --git a/README.zh.md b/README.zh.md index 1e8bfa6e..839575a0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -23,6 +23,8 @@ OPS 为 DSH 增加有界文件与文档工具。Native 模式返回紧凑、带行号的文本;PTC 代码从同一个按调用启动的 Rust worker 获取类型化 Outcome。宿主集成使用严格 TypeScript,编译后保持已有 JavaScript 包路径。 +支持:Windows x64、Linux x64,以及 macOS 13+ 的 Apple Silicon 与 Intel(`darwin-arm64`、`darwin-x64`)。见 [macOS](docs/macos.zh.md)。 + > [!IMPORTANT] > 本分支是 **0.4.0 发布候选**,尚未发布。已批准的验收范围为:BM-01 相比 OPS 0.3.x 输出减少至少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 400 token 上限,同时保留 Bash 启动常驻并单列完整总数。最终实测证据与功能 CI 缺一不可。 > 详情见[发布状态与限制](docs/0.4.0发布候选.md)。下方 `@latest` 命令安装已发布版本;评估候选版本需自行构建本检出目录并打包。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0ec16497..bbb78b78 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Add macOS Intel (`darwin-x64`) as a first-class native target on macOS 13+: accept it in the resolver and profile commands, declare it in every component row, ship its Mach-O worker with a receipt, and add a non-blocking `macos-15-intel` CI leg plus the fourth release artifact. The Intel leg measured 318 s, so it follows the >300 s non-blocking rule. macOS 12 is not part of this support: its kernel lacks `CLONE_ACL`, so the write path fails closed; that fallback is a separate follow-up. See [macOS](macos.md). +- 将 macOS Intel(`darwin-x64`)作为 macOS 13+ 的一等原生目标:解析器与 profile 命令接受它,所有组件行声明它,发布其 Mach-O worker 与收据,并新增非阻塞的 `macos-15-intel` CI leg 与第四个发布 artifact。Intel leg 实测 318 秒,因此按 >300 秒规则作为非阻塞信号。macOS 12 不在本次支持范围内:其内核缺少 `CLONE_ACL`,写路径会 fail closed;该回退是独立后续项。说明见 [macOS](macos.zh.md)。 - Make the Windows CI leg non-blocking while keeping it running on every event (#316): split `validate` into blocking `validate-posix` (Linux x64, macOS arm64) and `continue-on-error` `validate-windows`, and keep `ci-required` from depending on any `continue-on-error` job, so the aggregate neither waits for nor fails with the slowest leg. On `main` after #307 the Windows leg takes 527 / 529 s — up from 482 s before #307, because `npm ci` now downloads the 411 MB Windows Bash payload — against 136–139 s (Linux) and 168–182 s (macOS). `scripts/check-workflows.mjs` now enforces "every blocking job in the aggregate, no non-blocking job in it" and that the aggregate's failure loop names exactly the blocking jobs, so a job cannot stay in `needs` but escape judgement; the Windows result is a signal to act on, not a merge gate. - 将 CI 的 Windows 腿改为非阻塞、但仍每次触发都运行(#316):把 `validate` 拆成阻塞的 `validate-posix`(Linux x64、macOS arm64)与 `continue-on-error` 的 `validate-windows`,并让 `ci-required` 不依赖任何 `continue-on-error` job,聚合既不等待也不随最慢的一腿失败。`main` 在 #307 合入后实测 Windows 腿 527 / 529 秒(#307 之前 482 秒,因为 `npm ci` 现在要拉 411 MB 的 Windows Bash 载荷),Linux 136–139 秒、macOS 168–182 秒。`scripts/check-workflows.mjs` 现在强制“每个阻塞 job 都在聚合里、聚合里没有非阻塞 job”,并要求失败判定循环里的 job 集合与阻塞集合完全相等,避免 job 留在 `needs` 却逃过判定;Windows 结果是待处理的信号,不是合并门禁。 - Bound each `ops_replace` output to the same 32 MiB UTF-8 byte ceiling as file writes. Check expansion with overflow-safe arithmetic for the entire plan before constructing replacement content or publishing any file, including dry runs; an over-limit target returns `OUTPUT_LIMIT` with no file changes (#330). diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index 469f87db..0c32b781 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -50,6 +50,12 @@ FastCtx, `vendor/fastctx/`, the background component, or the FastCtx payload package, they describe those releases, not this one; those files remain in the repository history and in the published tarballs of the versions that carried them. +## Current target matrix (0.4.x) + +The current package declares `win32-x64`, `linux-x64`, `darwin-arm64` and `darwin-x64`. +The 0.3.1 record below is historical: it shipped Apple Silicon only and did not include +Intel. macOS 12's `CLONE_ACL` fallback is a separate follow-up; see [macOS](macos.md). + ## 0.3.1 release scope and verification 0.3.1 targets Windows x64, Linux x64 and macOS Apple Silicon (`darwin-arm64`). Intel Mac and other ARM64 targets are not supported. Exact target pairs live in `PLATFORMS` and component `targets`; npm os/cpu filters are only their coarse projection. Windows Bash/pwsh remain pinned Windows-only optional dependencies; POSIX uses host Bash. No postinstall downloader is included. diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index 3033fd8b..6d415db9 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -15,7 +15,7 @@ node bench/cli.mjs gates --baseline-source baseline-031 --baseline-worker baseli On Windows append `.exe` to the baseline worker. To measure the complete Bash surface, first install an optional fixture into a dedicated temporary `DSH_HOME` with `node bin/dsh-ops.mjs bash-runtime install`, then set `DSH_OPS_TEST_BASH` to its validated absolute executable (the release gate uses the same fixture API). The benchmark records this explicit selection; it changes no schema options. A fresh home without an installed Bash correctly blocks BM-03 instead of counting only the file schemas. The manual workflow acquires this fixture explicitly. Use a new output filename for each run. The command refuses to overwrite either JSON evidence or its `.md` summary. It retains its exclusively owned temporary fixture directory for inspection. `--task BM-02` selects one gate; selected success never means that all five passed. Any failed or unavailable selected gate exits 1; malformed arguments or missing build inputs exit 2. -The manual `Opt-in OPS acceptance benchmarks` workflow runs the same command on Windows x64, Linux x64 and Apple Silicon, independently builds the baseline, and uploads both artifacts even when a gate fails. It changes no default CI requirement and uses no model credentials. +The manual `Opt-in OPS acceptance benchmarks` workflow runs the same command on Windows x64, Linux x64 and Apple Silicon, independently builds the baseline, and uploads both artifacts even when a gate fails. The functional CI and release matrix also cover macOS Intel (`darwin-x64`), but the opt-in benchmark sweep does not include it yet. It changes no default CI requirement and uses no model credentials. ## Fixed workloads and comparisons diff --git a/docs/macos-arm64.md b/docs/macos-arm64.md deleted file mode 100644 index dc976e32..00000000 --- a/docs/macos-arm64.md +++ /dev/null @@ -1,42 +0,0 @@ -# macOS Apple Silicon (`darwin-arm64`) - -0.3.1 supports Apple Silicon, not Intel Mac. Exact native targets are `win32-x64`, `linux-x64` and `darwin-arm64`; the npm `os`/`cpu` projection is not a promise that every combination works. - -## Native worker and source build - -Published runtime-bearing packages use `runtime/darwin-arm64/dsh-ops-worker` with a version/target/executable/SHA256 receipt. A Git checkout does not contain generated workers. - -```console -npm ci --ignore-scripts -cargo build --release --locked --manifest-path native/Cargo.toml -node bin/native-runtime.mjs stage --platform darwin-arm64 --input native/target/release -node bin/native-runtime.mjs check --platform darwin-arm64 -npm run verify -``` - -Use a supported Rust toolchain (release validation pins 1.97.1). A custom `CARGO_TARGET_DIR` or `cargo --target` requires the matching explicit stage `--input`; never stage an old default-directory build by accident. The resolver refuses missing/corrupt receipts and Intel Mac targets. - -## Shells - -Bash is a **Windows-only optional npm payload** of the main package; PowerShell stays with the host. npm installs no payload on a Mac and there is no postinstall downloader. macOS `ops_bash` is enabled by default and resolves an explicit `bashPath`, then PATH and Homebrew/system locations. Example: - -```yaml -- id: dsh-ops-shell - name: dsh-ops/shell - config: - bashPath: /opt/homebrew/bin/bash -``` - -`/bin/bash` is usable when Homebrew Bash is not installed. Restricted calls still require DSH confinement; unavailable confinement is an executor failure, never permission to run unconfined. The host disables its pwsh tool on macOS, so provisioning a pwsh executable does not create that tool. - -## PTC and filesystem boundaries - -- file and opt-in PTC use the independent Rust worker. -- PTC tools publish only in pure PTC presentation. `batch.root` must be absolute when supplied; result paging of a batch audit must use the same root when it differs from the session root. -- macOS `/tmp` and `/var` are system aliases. The file bridge canonicalizes the **parent chain**, preserving the final entry for no-follow checks. Descendants of those aliases work; directly naming a symlink still fails closed. Canonical write-root checks remain in force. -- `replace` on macOS requires APFS clone support to preserve extended ACL metadata. Unsupported clone operations fail before publication rather than dropping a deny entry. Ordinary mode/owner/xattrs are restored before the final fsync and rename. -- Hidden `.dsh-ops-update-*.lock` sidecars are cooperative path locks, removed by the writer that holds one. A sidecar left by a killed writer is reused and removed by the next write of that name; it is never a stuck lock. - -## Verification evidence - -The manually dispatched three-platform workflow builds and tests a native Apple Silicon worker on an actual arm64 macOS runner. Final run links and the shipped artifact digests are recorded in the GitHub v0.3.1 Release. Contributor PR #67's earlier real-session observations are useful history, not a substitute for final integrated-head validation. Desktop GUI sessions still require a normal app restart after replacing the package. diff --git a/docs/macos.md b/docs/macos.md new file mode 100644 index 00000000..32564183 --- /dev/null +++ b/docs/macos.md @@ -0,0 +1,91 @@ +# macOS (arm64 and Intel x64) + +Supported on **macOS 13 or later**, natively on Apple Silicon (`darwin-arm64`) and Intel +(`darwin-x64`). Exact native targets are `win32-x64`, `linux-x64`, `darwin-arm64` and +`darwin-x64`; the npm `os`/`cpu` projection is not a promise that every combination works. + +macOS 12 is not fully supported: its kernel lacks `CLONE_ACL`, so overwriting an existing file, +`replace` and `copy` fail closed. Reads and non-mutating tools work. See [macOS 12](#macos-12) +below; the fallback is a separate follow-up. + +## Native worker and source build + +Published runtime-bearing packages use `runtime/darwin-arm64/dsh-ops-worker` or +`runtime/darwin-x64/dsh-ops-worker`, each with a version/target/executable/SHA256 receipt. A Git +checkout does not contain generated workers. + +```console +npm ci --ignore-scripts +cargo build --release --locked --manifest-path native/Cargo.toml + +# Apple Silicon +node bin/native-runtime.mjs stage --platform darwin-arm64 --input native/target/release +node bin/native-runtime.mjs check --platform darwin-arm64 + +# Intel +node bin/native-runtime.mjs stage --platform darwin-x64 --input native/target/release +node bin/native-runtime.mjs check --platform darwin-x64 +``` + +`npm run build:ptc` builds and stages the host target, so it selects `darwin-arm64` on Apple +Silicon and `darwin-x64` on Intel. A custom `CARGO_TARGET_DIR` or `cargo --target` requires the +matching explicit stage `--input`; never stage an old default-directory build by accident. The +resolver refuses missing/corrupt receipts and targets outside the matrix. + +## macOS 12 + +`replace`, `copy` and writes that overwrite an existing file stage a copy-on-write sibling with +`fclonefileat(..., CLONE_ACL)` so APFS ACLs and extended attributes survive. The xnu kernel in +macOS 12 (`xnu-8020`) does not implement `CLONE_ACL` and returns `EINVAL`; the worker reports +`METADATA_UNSUPPORTED` and publishes nothing. macOS 13, 14 and 15 (`xnu-8792`, `xnu-10002`, +`xnu-11215`) implement it. New files and read-only operations do not use the flag. Do not treat +macOS 12 as fully supported until the fallback lands; that fallback is a separate follow-up, not +part of this change. + +## Shells + +Bash is a **Windows-only optional npm payload** of the main package; PowerShell stays with the host. +npm installs no payload on a Mac and there is no postinstall downloader. macOS `ops_bash` is enabled +by default and resolves an explicit `bashPath`, then PATH and Homebrew/system locations: + +- Apple Silicon Homebrew: `/opt/homebrew/bin/bash` +- Intel Homebrew: `/usr/local/bin/bash` +- System Bash: `/bin/bash` + +```yaml +- id: dsh-ops-shell + name: dsh-ops/shell + config: + bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash +``` + +`/bin/bash` is usable when Homebrew Bash is not installed. Restricted calls still require DSH +confinement; unavailable confinement is an executor failure, never permission to run unconfined. +The host disables its pwsh tool on macOS, so provisioning a pwsh executable does not create that +tool. + +## PTC and filesystem boundaries + +- file and opt-in PTC use the independent Rust worker. +- PTC tools publish only in pure PTC presentation. `batch.root` must be absolute when supplied; + result paging of a batch audit must use the same root when it differs from the session root. +- macOS `/tmp` and `/var` are system aliases. The file bridge canonicalizes the **parent chain**, + preserving the final entry for no-follow checks. Descendants of those aliases work; directly + naming a symlink still fails closed. Canonical write-root checks remain in force. +- `replace` and overwrite operations on macOS require APFS clone support and `CLONE_ACL` + (macOS 13+). Unsupported clone operations fail before publication rather than dropping a deny + entry. Ordinary mode/owner/xattrs are restored before the final fsync and rename. +- Hidden `.dsh-ops-update-*.lock` sidecars are cooperative path locks, removed by the writer that + holds one. A sidecar left by a killed writer is reused and removed by the next write of that name; + it is never a stuck lock. + +## Verification evidence + +- CI runs the full gate on `macos-latest` (arm64, blocking) and `macos-15-intel` (x64, + non-blocking because the leg measured 318 s > 300 s; see [CI](ci.md)). +- The Intel leg builds and stages `darwin-x64`, runs all suites, checks the Mach-O x86_64 worker + and its receipt, and packs/unpacks the artifact. +- A local Intel macOS 12.7.6 run has 42 Rust unit tests passing and one expected `CLONE_ACL` + failure; that is why macOS 12 is a follow-up rather than part of this support claim. +- 0.3.1 shipped Apple Silicon only; current support adds Intel on macOS 13+. Desktop GUI sessions + still require a normal app restart after replacing the package. diff --git a/docs/macos.zh.md b/docs/macos.zh.md new file mode 100644 index 00000000..4606aa08 --- /dev/null +++ b/docs/macos.zh.md @@ -0,0 +1,82 @@ +# macOS(arm64 与 Intel x64) + +支持 **macOS 13 或更高版本**,原生覆盖 Apple Silicon(`darwin-arm64`)与 Intel(`darwin-x64`)。 +精确原生目标是 `win32-x64`、`linux-x64`、`darwin-arm64` 和 `darwin-x64`;npm 的 `os`/`cpu` +字段不能表达配对矩阵,实际支持以运行时常量和组件 `targets` 为准。 + +macOS 12 不是完整支持:其内核缺少 `CLONE_ACL`,覆盖已有文件、`replace` 与 `copy` 会 fail closed。 +读取与不改写文件的工具可用。原因和后续回退见下文 [macOS 12](#macos-12);该回退是独立的后续项, +不属于本次变更。 + +## 原生 worker 与源码构建 + +发布包使用 `runtime/darwin-arm64/dsh-ops-worker` 或 `runtime/darwin-x64/dsh-ops-worker`,各自带 +version/target/executable/SHA256 收据。Git 检出目录不包含生成的 worker。 + +```console +npm ci --ignore-scripts +cargo build --release --locked --manifest-path native/Cargo.toml + +# Apple Silicon +node bin/native-runtime.mjs stage --platform darwin-arm64 --input native/target/release +node bin/native-runtime.mjs check --platform darwin-arm64 + +# Intel +node bin/native-runtime.mjs stage --platform darwin-x64 --input native/target/release +node bin/native-runtime.mjs check --platform darwin-x64 +``` + +`npm run build:ptc` 构建并暂存宿主目标,因此在 Apple Silicon 上选 `darwin-arm64`,在 Intel 上选 +`darwin-x64`。自定义 `CARGO_TARGET_DIR` 或 `cargo --target` 必须用匹配的显式 stage `--input`; +不要把默认目录里的旧构建误当成本次构建。解析器拒绝缺失/损坏的收据和矩阵之外的目标。 + +## macOS 12 + +`replace`、`copy` 和覆盖已有文件的写入会通过 `fclonefileat(..., CLONE_ACL)` 创建 copy-on-write +副本,以保留 APFS ACL 与扩展属性。macOS 12 的 xnu(`xnu-8020`)没有实现 `CLONE_ACL`,返回 +`EINVAL`;worker 返回 `METADATA_UNSUPPORTED`,不会发布任何内容。macOS 13、14、15(`xnu-8792`、 +`xnu-10002`、`xnu-11215`)实现了该 flag。新文件与只读操作不使用它。在回退落地前,不要把 +macOS 12 当作完整支持;该回退是独立后续项,不属于本次变更。 + +## Shell + +Bash 是主包的 **Windows-only 可选 npm 载荷**;PowerShell 由宿主管理。macOS 不落地任何载荷,也没有 +postinstall 下载器。macOS 的 `ops_bash` 默认开启,按显式 `bashPath`、PATH、Homebrew/系统位置解析: + +- Apple Silicon Homebrew:`/opt/homebrew/bin/bash` +- Intel Homebrew:`/usr/local/bin/bash` +- 系统 Bash:`/bin/bash` + +```yaml +- id: dsh-ops-shell + name: dsh-ops/shell + config: + bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash +``` + +没有 Homebrew Bash 时 `/bin/bash` 可用。受限调用仍然要求 DSH 的宿主隔离;隔离不可用是执行器失败, +绝不是无隔离运行的理由。宿主在 macOS 上禁用 pwsh 工具,因此 provision 一个 pwsh 可执行文件也不会 +产生该工具。 + +## PTC 与文件系统边界 + +- file 与可选的 PTC 使用独立的 Rust worker。 +- PTC 工具只在纯 PTC 呈现中发布。提供 `batch.root` 时必须为绝对路径;批处理审计的分页在与会话根 + 不同时必须使用同一个根。 +- macOS 的 `/tmp`、`/var` 是系统别名。文件桥会规范化 **父目录链**,保留最后一项用于 no-follow + 检查;这些别名的后代可用,直接点名软链仍然 fail closed。规范化写根检查保持不变。 +- macOS 上的 `replace` 与覆盖写要求 APFS clone 支持与 `CLONE_ACL`(macOS 13+)。不支持 clone 的 + 文件系统会在发布前失败,而不是丢掉 deny 项;普通 mode/owner/xattr 在最终 fsync 与 rename 前恢复。 +- 隐藏的 `.dsh-ops-update-*.lock` sidecar 是协作式路径锁,由持有它的写入者删除。被杀死写入者留下的 + sidecar 会被同名文件的下一次写入复用并删除,不会成为死锁。 + +## 验证证据 + +- CI 在 `macos-latest`(arm64,阻塞)与 `macos-15-intel`(x64,非阻塞;该 leg 实测 318 秒, + 超过 300 秒线,见 [CI](ci.md))上跑完整门禁。 +- Intel leg 会构建并暂存 `darwin-x64`、跑全部套件、校验 Mach-O x86_64 worker 及其收据,并打包/解包 + artifact。 +- 本机 Intel macOS 12.7.6 上 42 个 Rust 单测通过、1 个预期 `CLONE_ACL` 失败;这正是 macOS 12 + 作为后续项、而不并入本次支持声明的原因。 +- 0.3.1 只发布 Apple Silicon;当前支持在 macOS 13+ 上增加 Intel。替换包之后,桌面 GUI 会话仍需 + 正常重启应用。 diff --git a/docs/native-ptc.md b/docs/native-ptc.md index e282e4ce..b2039726 100644 --- a/docs/native-ptc.md +++ b/docs/native-ptc.md @@ -1,4 +1,4 @@ -# Native PTC enhancement — Windows x64, Linux x64, Apple Silicon +# Native PTC enhancement — Windows x64, Linux x64, macOS arm64/x64 0.4.0 PTC callers use [Outcome contracts](ptc-outcomes.md) / [PTC 结果契约](PTC结果契约.md). The historical worker/recovery details below remain applicable; SDK results now wrap canonical facts in `data`. @@ -9,7 +9,7 @@ background component has been removed. ## Runtime-bearing installation and original DSH permission linkage -0.3.1 ships native workers for `win32-x64`, `linux-x64` and `darwin-arm64`, each with a version/checksum receipt, plus the Windows Bash IPC proxies. Formal `verify:release` requires all three target pairs and validates every PE/ELF/Mach-O worker and the Windows IPC binary/source closure. Local `check --staged` verifies only local development outputs. Intel Mac and Windows/Linux ARM64 are not supported. The Windows Bash payload remains a Windows-only optional dependency; PowerShell is host-managed. POSIX uses host Bash. There is no postinstall download/build. Source builds use `npm run build:ptc`, plus `npm run build:bash-ipc` on Windows. +The current release ships native workers for `win32-x64`, `linux-x64`, `darwin-arm64` and `darwin-x64`, each with a version/checksum receipt, plus the Windows Bash IPC proxies. 0.3.1 shipped the first three; current support adds Intel macOS on macOS 13+. Formal `verify:release` requires all four target pairs and validates every PE/ELF/Mach-O worker and the Windows IPC binary/source closure. Local `check --staged` verifies only local development outputs. Windows/Linux ARM64 are not supported. macOS 12 lacks `CLONE_ACL`; its write-path fallback is a separate follow-up, not part of this support claim. The Windows Bash payload remains a Windows-only optional dependency; PowerShell is host-managed. POSIX uses host Bash. There is no postinstall download/build. Source builds use `npm run build:ptc`, plus `npm run build:bash-ipc` on Windows. PTC/Bash use original per-call sandboxPolicy and sandbox.confine, reporting the host's full/partial enforcement honestly. Read-only permits existing result pages @@ -25,7 +25,7 @@ Real Windows provider and pure PTC SDK acceptance is covered in the release gate The fixed file component follows DSH fs-sandbox's trusted-operation policy fence, not arbitrary-code isolation; shell and PTC code still require confinement. -Runtime binaries target Windows x64, Linux x64 and macOS Apple Silicon; IPC proxies remain Windows x64 only. +Runtime binaries target Windows x64, Linux x64 and macOS arm64/x64; IPC proxies remain Windows x64 only. In `workspace-write`, native batch/result operations and their audit records are permitted under the selected workspace root through DSH confinement. Read-only @@ -362,9 +362,12 @@ Linux x64 (`x86_64-unknown-linux-gnu`): release build, `cargo test`, full `npm run verify` (every suite that does not need a FastCtx runtime, i.e. 28 passed and 2 skipped) and the native worker scenarios executed on the platform, including the DSH local sandbox's workspace/temporary-area policy fence. -macOS arm64 (`aarch64-apple-darwin`) is `cargo check --locked` only: compiling the -platform-specific code is not a run, and macOS is not a release claim. Actual -on-platform macOS run and real sandbox denial tests **still required before release**. +macOS arm64 (`aarch64-apple-darwin`) and macOS Intel (`x86_64-apple-darwin`) now run +the full gate in CI: `macos-latest` (arm64) is blocking, and `macos-15-intel` (x64) +is a non-blocking signal because the leg measured 318 s > 300 s. Both build and stage +their Mach-O worker, run the suite, and verify the receipt. The earlier 0.3.1 +`cargo check --locked`-only record is superseded. A local Intel macOS 12.7.6 run +still has one expected `CLONE_ACL` failure, so macOS 12 remains a follow-up. No fixed token/time saving percentage, actual model-turn benchmark, live Desktop upgrade, npm release or GitHub push is claimed. diff --git a/docs/release-process.md b/docs/release-process.md index 6d761f1e..6bdc3e4a 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -23,7 +23,7 @@ Sync master with main | active | 触发 | 行为 | | --- | --- | -| `push` tag `v*` | 完整跑:三个平台构建 → 门禁 → 打包 → 创建/更新 GitHub Release | +| `push` tag `v*` | 完整跑:四个目标构建 → 门禁 → 打包 → 创建/更新 GitHub Release | | `workflow_dispatch`(在 `main` 等分支上) | 干跑:构建 → 门禁 → 打包 → 上传 artifact,**不发布**(`publish` job 被 `github.ref_type == 'tag'` 挡掉,运行期也不授予写权限) | | `workflow_dispatch`(tag 选为 ref) | 完成一次中断的发布:每个发布步骤幂等,重跑不会重复上传或重造内容 | @@ -31,8 +31,8 @@ Sync master with main | active ## 流水线三段与失败语义 -1. **`native`(矩阵:`windows-latest`/`win32-x64`、`ubuntu-24.04`/`linux-x64`、`macos-latest`/`darwin-arm64`)** - - 只构建 [lib/ptc/runtime.js](../lib/ptc/runtime.js) 里声明的那三个目标;Intel macOS 与两种 arm64 变体不在矩阵内。 +1. **`native`(矩阵:`windows-latest`/`win32-x64`、`ubuntu-24.04`/`linux-x64`、`macos-latest`/`darwin-arm64`、`macos-15-intel`/`darwin-x64`)** + - 只构建 [lib/ptc/runtime.js](../lib/ptc/runtime.js) 里声明的那四个目标;Windows ARM64 与 Linux ARM64 不在矩阵内。 - 固定 Rust 1.97.1 与已锁定的 `Cargo.lock`;npm 平台名和 Rust triple 分列(例如 `win32-x64` 对应 `x86_64-pc-windows-msvc`),`cargo build --release --locked --target ` 失败即整次发布失败。 - `bin/native-runtime.mjs stage` 为目标写入 `runtime//provenance.json`(version/target/executable/sha256)——这就是本仓库的 provenance 约定;缺二进制或不是常规文件都会报错。 - Windows 目标额外跑 `npm run build:bash-ipc`,其 receipt 由门禁按仓库内的 `native/bash-ipc/*` 源码哈希复核。 @@ -40,9 +40,9 @@ Sync master with main | active - 这里**故意不做 `actions/cache` 恢复**:发布二进制在冷目录里由该 tag 的源码构建,不会被上一次运行的缓存顶替。 2. **`gate`(`windows-latest`,只读权限)** - tag 与 `package.json` 版本不一致 → 直接失败(不进入打包)。 - - 下载三个 artifact 分别放回 `runtime/win32-x64`、`runtime/linux-x64`、`runtime/darwin-arm64`,不把各目标的 `provenance.json` 展平覆盖。 + - 下载四个 artifact 分别放回 `runtime/win32-x64`、`runtime/linux-x64`、`runtime/darwin-arm64`、`runtime/darwin-x64`,不把各目标的 `provenance.json` 展平覆盖。 - 单独编译 gate 宿主 worker 到 `native/target/release` 供固定路径的真实套件使用;这份测试 build 不替换来自冷构建矩阵的 staged 发布产物。 - - **`npm run verify:release`**(`validate-manifest` + 全部仓库测试 + `bin/check-release.mjs`)是硬门禁:三目标缺一、receipt 与字节哈希不符、Windows IPC 二进制/源码哈希不符、`dsh-plugin.json` 与 runtime 白名单不一致,任一都失败。 + - **`npm run verify:release`**(`validate-manifest` + 全部仓库测试 + `bin/check-release.mjs`)是硬门禁:四目标缺一、receipt 与字节哈希不符、Windows IPC 二进制/源码哈希不符、`dsh-plugin.json` 与 runtime 白名单不一致,任一都失败。 - 之后才 `build-platform-packages.mjs --dist`(组装主包与固定的 Windows bash 载荷包并打 tarball,写 `provenance.json` 与 `SHA256SUMS`),再 `... check --dist`(只重读已记录的摘要,不下载不构建)。 - dist 目录以 `release-dist` 上传,`if-no-files-found: error`。 - 同号载荷包的权威字节在 npm registry:Release 里附带的 `dsh-ops-bash-win32-x64-.tgz` 是本次构建记录(`provenance.json` 带 `builtAt`/`builtFromCommit`),与 registry 上同版本 tarball 不会字节相同;离线校验以 registry 的 `integrity` 为准。 @@ -74,7 +74,7 @@ gh api repos/T-Auto/dsh-ops/actions/workflows --jq '.workflows[]|select(.name==" ## 启用后:先手动干跑一次,再打 tag -1. Actions → Release → **Run workflow**,分支选 `main`,不选 tag。预期:`native` 矩阵三个分支全绿、`gate` 绿、`publish` **skipped**,并产出 `native-win32-x64`/`native-linux-x64`/`native-darwin-arm64`/`release-dist` 四个 artifact。 +1. Actions → Release → **Run workflow**,分支选 `main`,不选 tag。预期:`native` 矩阵四个分支全绿、`gate` 绿、`publish` **skipped**,并产出 `native-win32-x64`/`native-linux-x64`/`native-darwin-arm64`/`native-darwin-x64`/`release-dist` 五个 artifact。 2. 检查 `release-dist` 里有一个主包 tarball、`SHA256SUMS`、`provenance.json`;把 `SHA256SUMS` 与 run summary 里的摘要对上。 3. 干跑全绿之前**不要**打 tag:`push` tag 会立即发布,而 Release 一旦创建就对外可见(可以改 notes、可以替换资产,但"没发布过"这件事收不回来)。 4. 需要真正发布时:`git tag v` → push tag(或按上面第 1 步改用 tag 作为 ref 手动 dispatch)。tag 与 `package.json` 版本必须一致,否则 `gate` 会拒绝。 diff --git a/docs/roadmap.md b/docs/roadmap.md index cce809aa..d319f063 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,18 +11,21 @@ - **0.3.0** — native Rust file tools and confined PTC batching (Windows x64). - **0.3.1** — cross-platform: Windows x64, Linux x64 and macOS Apple Silicon (`darwin-arm64`). See [linux-build.md](linux-build.md) and - [macos-arm64.md](macos-arm64.md). + [macos.md](macos.md). ## 0.4.0 candidate Separate Native/PTC projections, scoped discovery, bounded CSV/TSV, DOCX and restricted PDF readers, runtime/settings changes and strict TypeScript source are implemented. This is not a release announcement: final evidence must satisfy the approved OPS 0.3.x BM-01 threshold and file/discovery BM-03 budget, while separately reporting the official comparison and full Bash-inclusive startup total. See [candidate status and format limits](release-0.4.0.md). +- macOS Intel (`darwin-x64`) on macOS 13+ is implemented and in review; macOS 12's + `CLONE_ACL` fallback remains a separate follow-up. See [macOS](macos.md). + ## Later ### Platform coverage - Windows ARM64 and Linux ARM64 targets. -- Intel Mac (`darwin-x64`). +- macOS 12 (`CLONE_ACL` fallback for Intel and arm64) after the macOS 13+ support lands. ### Tool surface (progressive disclosure) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4ba60e89..f9403572 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -180,16 +180,26 @@ Legacy explicitly provisioned `/dsh-ops/` files must be cleaned separa No. It is FastCtx's reusable shared process, started by earlier versions with the background component; this version starts none. With no clients and about ten minutes of inactivity, it exits normally. Do not kill it by process-name sweeps. This release does not automatically sweep processes left by older versions. -## macOS arm64 +## macOS (arm64 and Intel) ### There is no bundled bash or PowerShell payload -macOS ships no `@dsh-ops/bash-darwin-arm64` or `@dsh-ops/pwsh-darwin-arm64` +macOS ships no `@dsh-ops/bash-darwin-*` or `@dsh-ops/pwsh-darwin-*` package, and no portable upstream bash exists for macOS. Select the shell the -plugin should use with `bashPath` (for example `/opt/homebrew/bin/bash`) on the -`dsh-ops/shell` row. PowerShell 7 can be provisioned, but the host disables its `pwsh-sandbox` and +plugin should use with `bashPath` (for example `/opt/homebrew/bin/bash` on Apple +Silicon, `/usr/local/bin/bash` on Intel, or `/bin/bash`) on the `dsh-ops/shell` +row. PowerShell 7 can be provisioned, but the host disables its `pwsh-sandbox` and `tool-pwsh` rows outside Windows, so no pwsh tool appears on macOS. +### macOS 12: replace or copy fails with `METADATA_UNSUPPORTED` + +Overwriting an existing file, `replace` and `copy` use `fclonefileat(..., CLONE_ACL)` +to preserve APFS ACLs. macOS 12's kernel does not implement that flag and returns +`EINVAL`; the worker fails closed with `METADATA_UNSUPPORTED` and publishes +nothing. This is an OS-version limit, not an Intel-specific defect. Use macOS 13+ +for the write path, or keep to read-only tools; a macOS 12 fallback is tracked as +a separate follow-up. See [macOS](macos.md) for the full matrix. + ### The file tools reject a path under `/tmp` `/tmp` is a symlink to `/private/tmp` on macOS, and the native file worker diff --git a/docs/tui-panel.md b/docs/tui-panel.md index 9d6213bf..9a8e9839 100644 --- a/docs/tui-panel.md +++ b/docs/tui-panel.md @@ -87,4 +87,4 @@ DSH_OPS_TUI_ROOT=/path/to/dsh-TUI DSH_OPS_TUI_WORKER=/absolute/path/to/dsh-ops/n This uses the real Rust worker, OPS file runtime, DSH tool runtime, AgentLoop and Session post-commit notifications, plus the real admitted sidebar and renderer. Model responses and policy selection are scripted fixtures; no online model is called. It covers native file reads, bounded listings, dry-run replacement and read-only refusal. PTC batch/result execution, production PTC-only publication, run_code execution, disk-persistence durability, Windows confinement and manual PTY acceptance are **not** covered. Only owned temporary fixtures are mutated; HOME/USERPROFILE/DSH_HOME stay isolated. The suite neither builds/downloads a worker nor changes the release platform resolver. Missing opt-in prerequisites are counted as skipped; the required flag makes them fail. -The sidebar does not change the package's declared targets: Windows x64, Linux x64 and macOS arm64. UI tests alone do not establish native execution or OS sandbox acceptance. +The sidebar does not change the package's declared targets: Windows x64, Linux x64, macOS arm64 and macOS Intel x64. UI tests alone do not establish native execution or OS sandbox acceptance. diff --git a/docs/tui-panel.zh.md b/docs/tui-panel.zh.md index ea57578e..13491782 100644 --- a/docs/tui-panel.zh.md +++ b/docs/tui-panel.zh.md @@ -87,4 +87,4 @@ DSH_OPS_TUI_ROOT=/path/to/dsh-TUI DSH_OPS_TUI_WORKER=/absolute/path/to/dsh-ops/n 使用真实 Rust worker、OPS 文件运行时、DSH 工具运行时、AgentLoop、Session 提交后通知,以及真实准入的侧栏与渲染器。模型响应和策略选择是脚本夹具,不调用在线模型。覆盖原生文件读取、有界列表、dry-run 替换和只读拒绝;**不覆盖** PTC batch/result 执行、生产 PTC-only 发布门禁、run_code 执行、磁盘持久化耐久性、Windows 沙箱或手工 PTY 验收。只修改测试自有临时文件,隔离 HOME/USERPROFILE/DSH_HOME。用例本身不构建/下载 worker,也不改发布平台解析器。缺少 opt-in 前提时记为跳过,required 标志使其失败。 -侧栏不改变整包声明的 Windows x64、Linux x64 和 macOS arm64 目标;UI 测试本身不能替代原生执行或 OS 沙箱验收。 +侧栏不改变整包声明的 Windows x64、Linux x64、macOS arm64 与 macOS Intel x64 目标;UI 测试本身不能替代原生执行或 OS 沙箱验收。 diff --git a/docs/usage.md b/docs/usage.md index cced155a..bc63c2d7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -20,21 +20,22 @@ forms and the optional browser carrier. Use [manual acceptance](manual-validatio | Item | Requirement | | --- | --- | -| OS | Windows x64, Linux x64 or macOS arm64; OPS Bash is on by default on all three (pinned payload on Windows, distribution Bash on POSIX) | +| OS | Windows x64, Linux x64 or macOS 13+ (arm64 or Intel x64); OPS Bash is on by default on all of them (pinned payload on Windows, distribution Bash on POSIX) | | Node.js | `^22.19 \|\| >=24` (the `engines` field in `package.json`) | | Host | DSH `0.2.0-rc.2` | | Target application | Initialized | -### macOS arm64 +### macOS (arm64 and Intel) macOS carries no bundled shell payload, and the native worker is not part of the -repository on any platform, so two extra steps apply. - -Build and stage the worker once from the checkout (Rust >= 1.88): +repository on any platform. Build and stage the worker for the host: ```console cargo build --release --manifest-path native/Cargo.toml +# Apple Silicon node bin/native-runtime.mjs stage --platform darwin-arm64 +# Intel +node bin/native-runtime.mjs stage --platform darwin-x64 ``` Then point the shell row at a bash the machine already has: @@ -44,14 +45,18 @@ Then point the shell row at a bash the machine already has: name: dsh-ops/shell disabled: false config: - bashPath: /opt/homebrew/bin/bash + bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash ``` +`/bin/bash` also works. macOS 12 is not fully supported: the write path needs +`CLONE_ACL`, which macOS 13+ provides; read-only tools still work. See +[macOS (arm64 and Intel)](macos.md) for the capability matrix and the known +limitations. + The desktop application's bundled CLI lives inside its own installation, at `.../Contents/Resources/runtime/cli/bin/dsh` on macOS, rather than at the `%LOCALAPPDATA%` path used on Windows. The host exposes no pwsh row on macOS, so -PowerShell 7 is unavailable there. See [macOS arm64](macos-arm64.md) for the -capability matrix and the known limitations. +PowerShell 7 is unavailable there. --- diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index b82fdafb..11da61f3 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -13,20 +13,21 @@ | 项 | 要求 | | --- | --- | -| 操作系统 | Windows x64、Linux x64 或 macOS Apple Silicon(darwin-arm64) | +| 操作系统 | Windows x64、Linux x64,或 macOS 13+(arm64 / Intel x64) | | Node.js | `^22.19 \|\| >=24`(`package.json` 的 `engines`) | | 宿主 | DSH `0.2.0-rc.2` | | 目标应用 | 已初始化 | -### macOS arm64 +### macOS(arm64 与 Intel) -macOS 没有自带 shell 载荷,原生 worker 也不在任何平台的仓库里,因此多两步。 - -先在检出目录构建并 stage 一次原生 worker(需要 Rust >= 1.88): +macOS 没有自带 shell 载荷,原生 worker 也不在任何平台的仓库里。先在检出目录为宿主构建并 stage: ```console cargo build --release --manifest-path native/Cargo.toml +# Apple Silicon node bin/native-runtime.mjs stage --platform darwin-arm64 +# Intel +node bin/native-runtime.mjs stage --platform darwin-x64 ``` 然后在 shell 组件行用 `bashPath` 指向本机已有的 bash: @@ -36,12 +37,15 @@ node bin/native-runtime.mjs stage --platform darwin-arm64 name: dsh-ops/shell disabled: false config: - bashPath: /opt/homebrew/bin/bash + bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash ``` +`/bin/bash` 也可用。macOS 12 不是完整支持:写路径需要 `CLONE_ACL`,只有 macOS 13+ 提供; +只读工具仍可用。能力矩阵与已知限制见 [macOS(arm64 与 Intel)](macos.zh.md)。 + macOS 上桌面应用自带的 CLI 位于其安装包内(`.../Contents/Resources/runtime/cli/bin/dsh`), 而不是 Windows 的 `%LOCALAPPDATA%` 路径。宿主在 macOS 上不暴露 pwsh 行,因此 -PowerShell 7 在 macOS 上不可用。能力矩阵与已知限制见 [macOS arm64](macos-arm64.md)。 +PowerShell 7 在 macOS 上不可用。 --- diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index e8d624dc..ccc6a420 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -15,7 +15,7 @@ node bench/cli.mjs gates --baseline-source baseline-031 --baseline-worker baseli Windows 的基线 worker 路径须加 `.exe`。每次运行使用新的输出文件名,JSON 证据与 `.md` 摘要都拒绝覆盖。工具保留独占创建的临时夹具目录以便检查。`--task BM-02` 仅选择一个门禁,其成功不能代表五项全部通过。所选项失败或不可用时退出 1;参数错误或缺少构建输入时退出 2。 -手动工作流 `Opt-in OPS acceptance benchmarks` 在 Windows x64、Linux x64 和 Apple Silicon 上运行同一命令,独立构建基线,并在门禁失败时仍上传两份产物。默认 CI 要求不变,无需模型凭据。 +手动工作流 `Opt-in OPS acceptance benchmarks` 在 Windows x64、Linux x64 和 Apple Silicon 上运行同一命令,独立构建基线,并在门禁失败时仍上传两份产物。功能 CI 与发布矩阵也覆盖 macOS Intel(`darwin-x64`),但这个按需基准矩阵尚未包含它。默认 CI 要求不变,无需模型凭据。 ## 固定工作负载与比较方式 diff --git "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" index 2873ce07..ee3ff8c6 100644 --- "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" @@ -169,11 +169,15 @@ provisioned 副本在 `/dsh-ops/shells`(跨 profile 共享,provisi 不是。它是 FastCtx 的可复用共享进程,由带后台组件的早期版本启动;本版本不会启动它。无客户端且空闲约十分钟后自行退出。不要按进程名批量杀它。新版不自动清理旧版本已留下的进程。 -## macOS arm64 +## macOS(arm64 与 Intel) ### 没有自带的 bash / PowerShell 载荷 -macOS 没有 `@dsh-ops/bash-darwin-arm64` 或 `@dsh-ops/pwsh-darwin-arm64`,也没有可移植的官方 macOS bash。在 `dsh-ops/shell` 行用 `bashPath` 指定要使用的 bash(例如 `/opt/homebrew/bin/bash`)。PowerShell 7 可以 provision,但宿主在非 Windows 上禁用了 `pwsh-sandbox` 与 `tool-pwsh` 两行,所以 macOS 上不会出现 pwsh 工具。 +macOS 没有 `@dsh-ops/bash-darwin-*` 或 `@dsh-ops/pwsh-darwin-*`,也没有可移植的官方 macOS bash。在 `dsh-ops/shell` 行用 `bashPath` 指定要使用的 bash(Apple Silicon 例如 `/opt/homebrew/bin/bash`,Intel 例如 `/usr/local/bin/bash`,或 `/bin/bash`)。PowerShell 7 可以 provision,但宿主在非 Windows 上禁用了 `pwsh-sandbox` 与 `tool-pwsh` 两行,所以 macOS 上不会出现 pwsh 工具。 + +### macOS 12:replace/copy 返回 `METADATA_UNSUPPORTED` + +覆盖已有文件、`replace` 与 `copy` 使用 `fclonefileat(..., CLONE_ACL)` 保留 APFS ACL。macOS 12 的内核没有实现该 flag,返回 `EINVAL`;worker 以 `METADATA_UNSUPPORTED` fail closed,不发布任何内容。这是 OS 版本限制,不是 Intel 专有缺陷。写路径请使用 macOS 13+;只读工具仍可用。macOS 12 的回退作为独立后续项跟踪。完整矩阵见 [macOS](macos.zh.md)。 ### 文件工具拒绝 `/tmp` 下的路径 diff --git "a/docs/\350\267\257\347\272\277\345\233\276.md" "b/docs/\350\267\257\347\272\277\345\233\276.md" index d613ad27..f0d5df66 100644 --- "a/docs/\350\267\257\347\272\277\345\233\276.md" +++ "b/docs/\350\267\257\347\272\277\345\233\276.md" @@ -13,9 +13,11 @@ English: [Roadmap](roadmap.md). Native/PTC 独立投影、作用域发现、有界 CSV/TSV、DOCX 与受限 PDF 读取、运行时/设置变更和严格 TypeScript 源码已实现。本页不是发布公告:最终证据须满足已批准的 OPS 0.3.x BM-01 门槛与文件/发现 BM-03 预算,同时单列官方对照及含 Bash 的完整启动总数。范围和格式限制见[候选说明](0.4.0发布候选.md)。 +- macOS Intel(darwin-x64)在 macOS 13+ 上的支持已实现、待审阅;macOS 12 的 `CLONE_ACL` 回退是独立后续项,见 [macOS](macos.zh.md)。 + ## 后续方向 -- Windows ARM64、Linux ARM64、Intel Mac 平台。 +- Windows ARM64、Linux ARM64 平台;macOS 12 的 `CLONE_ACL` 回退(Intel 与 arm64)在 macOS 13+ 支持落地后处理。 - 扩展 0.4.0 有界 DOCX/PDF 子集以外的文档支持。 - Web 搜索、HTML 转 Markdown、更多 PTC 工具与有界结果投影。 - 同步双语文档并补充故障排查;跟踪上游 DSH 接口变化。 diff --git a/package.json b/package.json index 6d87d734..537998c1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "x64", "arm64" ], - "description": "Native file and PTC tools for DSH on Windows x64, Linux x64 and macOS Apple Silicon; Windows-only optional Bash payload and host PowerShell integration", + "description": "Native file and PTC tools for DSH on Windows x64, Linux x64 and macOS arm64/x64; Windows-only optional Bash payload and host PowerShell integration", "icon": "./docs/assets/icon.png", "keywords": [ "dsh", From 58098079d151ce3bff55c5a260c3a5c82de0e034 Mon Sep 17 00:00:00 2001 From: OldLigant Date: Thu, 22 Oct 2026 19:43:04 +0800 Subject: [PATCH 09/50] fix(ci): close the Intel shell-payload check command The new validate-macos-intel step copied the node -e check without its closing brace, so Node exited with SyntaxError before the check ran. Restore the brace; the blocking legs were unaffected. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8f7c222..8ebe7e51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,7 +145,7 @@ jobs: run: node bin/native-runtime.mjs check --platform darwin-x64 - name: Check shell payload platform filtering shell: bash - run: node -e "const fs=require('fs');if(process.platform!=='win32'){for(const n of ['bash','pwsh'])if(fs.existsSync('node_modules/@dsh-ops/'+n+'-win32-x64'))throw Error('Windows shell installed on POSIX')" + run: node -e "const fs=require('fs');if(process.platform!=='win32'){for(const n of ['bash','pwsh'])if(fs.existsSync('node_modules/@dsh-ops/'+n+'-win32-x64'))throw Error('Windows shell installed on POSIX')}" - uses: actions/upload-artifact@v7 with: name: native-darwin-x64 From bf66158dc08be533e30eb486d30b00c37265c312 Mon Sep 17 00:00:00 2001 From: ikliml666 Date: Fri, 23 Oct 2026 21:01:43 +0800 Subject: [PATCH 10/50] fix(ptc): keep republish state out of the live-validity guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reconcile` advanced `epoch` on every call, so the passes the new `targets` argument turns into no-ops still invalidated every published `execute` guard and every Native shadow. Those then answered POLICY_DENIED for a scope that was still authoritative, which is why `discovery-composition` lost its combined Native/PTC checks and `ptc` lost its presentation-change race. A probe also recorded its surface on the entry before the republish that consumes it ran. A pass a later reconcile superseded therefore left the next pass reading that surface as already published, returning early forever; the PTC verdict never took effect and `native-result` timed out waiting for the Native/PTC projections. A separate `published` counter now moves only when a pass actually republishes. `ptc`'s shadows and `discovery`'s guards compare it, because `discovery`'s probe assembles the prompt and that reconciles other scopes. Probed surfaces stay pending until the republish commits them. Verified: `npm run typecheck`, `npm run verify` — 125 of 127 suites pass, the two remaining are the local-only failures `bash-ipc` and `permissions`, which need the pinned runtime receipts this checkout does not build and fail identically on b963fb2. --- docs/CHANGELOG.md | 2 ++ src/discovery/index.ts | 11 +++++++---- src/ptc/index.ts | 18 +++++++++++++----- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3efa953e..495e0a5a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Keep the per-agent re-probe from retiring live registrations or stranding an unpublished surface. `reconcile` advanced its epoch on every call, so the passes the new `targets` argument turns into no-ops still invalidated every published `execute` guard and every Native shadow, which then answered `POLICY_DENIED` for a scope that was still authoritative; and a probe recorded its surface on the entry before the republish that consumes it ran, so a pass a later reconcile superseded left the next pass reading that surface as already published and returning early forever, which is how a PTC verdict could never take effect. A separate `published` counter now moves only when a pass actually republishes, the guards compare it, and probed surfaces stay pending until that republish commits them. `discovery` compares `published` for the same reason its probe cannot: assembling the prompt reconciles other scopes (#368). +- 让逐 agent 重探针不再作废仍有效的注册,也不再把未发布的结果留在 entry 上。`reconcile` 每次调用都推进 epoch,于是新增 `targets` 参数变成空转的那些 pass 仍会作废所有已发布的 `execute` 守卫与 Native 影子工具,它们随后对仍然有效的 scope 返回 `POLICY_DENIED`;探针还会在消费它的那次重发布执行之前就把 surface 记到 entry 上,被后一次 reconcile 取代的 pass 由此让下一个 pass 把该 surface 当作已发布而永远提前返回,PTC 判定因此始终无法生效。现在单独的 `published` 计数器只在某个 pass 真正重发布时推进,守卫改为比较它,探针结果保持待提交直到那次重发布写入。`discovery` 同样比较 `published`:它的探针要装配提示词,而装配会 reconcile 其它 scope(#368)。 - Stop re-probing every tracked agent on each PTC publish. `ptc` and `discovery` both assembled the entire prompt once per agent per reconcile, so a new session paid for every session still tracked: on Windows x64 with 38 bundles the same `session/create` took 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms over eight rounds. Each agent now memoizes its presentation verdict against the tool names it publishes and re-probes only when that set changes, `reconcile` accepts the agents a change actually touches, and the per-publish tool snapshot reads names instead of rebuilding a scope view per tool. The same eight rounds now measure 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms (#333, #359). - 不再在每次 PTC 发布时重新探测全部已跟踪 agent。`ptc` 与 `discovery` 都在每次 reconcile 里对每个 agent 完整装配一次提示词,因此新建一个会话要付出所有仍在跟踪的会话的代价:Windows x64、38 个 bundle 下,同一个 `session/create` 八轮为 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms。现在每个 agent 按自己发布的工具名缓存呈现判定、只有该集合变化时才重新探测,`reconcile` 接受「本次真正受影响的 agent」,每次发布的工具快照只读工具名而不再为每个工具重建一次 scope 视图。同样八轮实测为 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms(#333、#359)。 diff --git a/src/discovery/index.ts b/src/discovery/index.ts index d6394af4..0fbb1113 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -47,7 +47,7 @@ export function apply(root: Context, raw: unknown): void { const entries = new Map() mountResultLifecycle(ctx) let ownership: Awaited> | undefined - let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() + let stopped = false, mutating = false, epoch = 0, published = 0, chain = Promise.resolve() let settled = chain const drain = async () => { let current: Promise @@ -92,9 +92,12 @@ export function apply(root: Context, raw: unknown): void { const execute = tool.execute const registration: ToolDefinition = { ...tool, execute: async (args, exec) => { await chain - const version = epoch + // The probe below assembles the prompt, which reconciles other scopes; `epoch` would + // therefore invalidate this call for changes that never republished this one. What + // retires a registration is a republish, so the guard compares `published`. + const version = published const available = () => ownership?.owns(agent) && !stopped && entries.get(agent) === entry && exec.agent === agent && entry.owned.has(tool.name) - if (!available() || !await native(agent).catch(() => false) || !available() || version !== epoch) { + if (!available() || !await native(agent).catch(() => false) || !available() || version !== published) { return failure('POLICY_DENIED', 'Discovery capability belongs to a different or inactive scope.') } return execute(args, exec) @@ -112,7 +115,7 @@ export function apply(root: Context, raw: unknown): void { const ready: [Agent, Entry][] = [] for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && permitted(agent) && await settledNative(agent)) ready.push([agent, entry]) if (stopped || version !== epoch) return - mutating = true; ownership?.begin() + mutating = true; ownership?.begin(); published = version try { for (const [agent, entry] of entries) { if (!ownership?.owns(agent)) { clear(entry); entry.releaseSelection?.(); entry.releaseSelection = undefined; continue } diff --git a/src/ptc/index.ts b/src/ptc/index.ts index 4d476156..08e20869 100644 --- a/src/ptc/index.ts +++ b/src/ptc/index.ts @@ -47,7 +47,7 @@ export function apply(root: Context, rawConfig: unknown): void { mountResultLifecycle(ctx) const entries = new Map() const active = new Map }>() - let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() + let stopped = false, mutating = false, epoch = 0, published = 0, chain = Promise.resolve() let settled = chain const drain = async () => { let current: Promise @@ -76,6 +76,8 @@ export function apply(root: Context, rawConfig: unknown): void { let ownership: Awaited> | undefined const execute = (agent: Agent, entry: Entry, tool: ApiName | 'load_sdk'): ToolDefinition['execute'] => async (args, exec) => { await chain + // A call reads the live epoch: any later reconcile invalidates it, whether or not that + // pass ended up publishing. const version = epoch const available = () => !stopped && entries.get(agent) === entry && ownership?.owns(agent) && exec.agent === agent && entry.ptc && entry.owned.has(publicToolName(tool)) if (!available() || !await ptcOnly(agent, exec.signal).catch(() => false) || !available() || version !== epoch) { @@ -118,6 +120,11 @@ export function apply(root: Context, rawConfig: unknown): void { // A republish re-registers every agent's definitions. It is only owed when some // agent's published surface or PTC verdict actually moved; `force` asks for a // fresh probe of the selected agents, not for an unconditional rewrite. + // Probed surfaces stay pending until the republish that consumes them runs. A pass + // a later reconcile supersedes publishes nothing, so recording its surfaces would + // leave the next pass reading an unpublished surface as already current and + // returning early forever. + const surfaces = new Map() let dirty = false for (const [agent, entry] of entries) { if (stopped || version !== epoch) return @@ -126,15 +133,16 @@ export function apply(root: Context, rawConfig: unknown): void { const surface = surfaceOf(agent) if (!force && entry.surface === surface) { if (entry.ptc) ready.add(agent); continue } if (entry.surface !== surface) dirty = true - entry.surface = surface + surfaces.set(agent, surface) const next = await ptcOnly(agent).catch(() => false) if (next !== entry.ptc) dirty = true if (next) ready.add(agent) } if (stopped || version !== epoch) return if (!dirty && !force && !republishAnyway) return - mutating = true; ownership?.begin() + mutating = true; ownership?.begin(); published = version try { + for (const [agent, surface] of surfaces) { const entry = entries.get(agent); if (entry) entry.surface = surface } for (const entry of entries.values()) clear(entry) // Snapshot exact Native visibility only after all our masks/overrides have lifted. const native = new Map([...entries].map(([agent, entry]) => [agent, nativeProviders(entry.ctx ?? ctx, agent, true)])) @@ -158,9 +166,9 @@ export function apply(root: Context, rawConfig: unknown): void { const def = provider.definition if (provider.scope === agent || tools.get(def.name, agent) === def) continue const shadow: ToolDefinition = { ...def, execute: async (args, exec) => { - const version = epoch + const version = published if (stopped || !provider.active || entries.get(agent) !== entry || exec.agent !== agent || await ptcOnly(agent, exec.signal).catch(() => true) - || stopped || !provider.active || version !== epoch) return failure('POLICY_DENIED', 'Native projection or owning scope is unavailable.') + || stopped || !provider.active || version !== published) return failure('POLICY_DENIED', 'Native projection or owning scope is unavailable.') return def.execute(args, exec) } } entry.disposers.push(entry.ctx.tools.register(shadow), trackNativeShadow(entry.ctx, agent, shadow, provider)); entry.owned.set(def.name, shadow) From 02523333243d3c65980014340f164ac9d6b03f96 Mon Sep 17 00:00:00 2001 From: ikliml666 Date: Fri, 23 Oct 2026 22:07:41 +0800 Subject: [PATCH 11/50] fix(ptc): probe only the agents a pass names, and share the surface snapshot A departure passed an empty target set, which the pass normalized to "no restriction" and then probed every tracked agent on the one pass that has nothing to probe. An empty set is now a probe-free pass: it names nobody to probe while still asking for the republish a departure owes. The per-publish snapshot now reads tool names through one src/surface.ts helper instead of an identical surfaceOf in each plugin, and both memo keys document that names are the whole key while the presentation verdict also reads the tools:ptc-only section text, so a flip without a name change costs one pass and never a permanent wrong surface. --- docs/CHANGELOG.md | 4 +-- src/discovery/index.ts | 9 ++++-- src/ptc/index.ts | 29 ++++++++++-------- src/surface.ts | 17 +++++++++++ test/ptc-departure.test.mjs | 59 +++++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 src/surface.ts create mode 100644 test/ptc-departure.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 495e0a5a..4b99eb48 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,8 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -- Keep the per-agent re-probe from retiring live registrations or stranding an unpublished surface. `reconcile` advanced its epoch on every call, so the passes the new `targets` argument turns into no-ops still invalidated every published `execute` guard and every Native shadow, which then answered `POLICY_DENIED` for a scope that was still authoritative; and a probe recorded its surface on the entry before the republish that consumes it ran, so a pass a later reconcile superseded left the next pass reading that surface as already published and returning early forever, which is how a PTC verdict could never take effect. A separate `published` counter now moves only when a pass actually republishes, the guards compare it, and probed surfaces stay pending until that republish commits them. `discovery` compares `published` for the same reason its probe cannot: assembling the prompt reconciles other scopes (#368). -- 让逐 agent 重探针不再作废仍有效的注册,也不再把未发布的结果留在 entry 上。`reconcile` 每次调用都推进 epoch,于是新增 `targets` 参数变成空转的那些 pass 仍会作废所有已发布的 `execute` 守卫与 Native 影子工具,它们随后对仍然有效的 scope 返回 `POLICY_DENIED`;探针还会在消费它的那次重发布执行之前就把 surface 记到 entry 上,被后一次 reconcile 取代的 pass 由此让下一个 pass 把该 surface 当作已发布而永远提前返回,PTC 判定因此始终无法生效。现在单独的 `published` 计数器只在某个 pass 真正重发布时推进,守卫改为比较它,探针结果保持待提交直到那次重发布写入。`discovery` 同样比较 `published`:它的探针要装配提示词,而装配会 reconcile 其它 scope(#368)。 +- Keep the per-agent re-probe from retiring live registrations or stranding an unpublished surface. `reconcile` advanced its epoch on every call, so the passes the new `targets` argument turns into no-ops still invalidated every published `execute` guard and every Native shadow, which then answered `POLICY_DENIED` for a scope that was still authoritative; and a probe recorded its surface on the entry before the republish that consumes it ran, so a pass a later reconcile superseded left the next pass reading that surface as already published and returning early forever, which is how a PTC verdict could never take effect. A separate `published` counter now moves only when a pass actually republishes, the guards compare it, and probed surfaces stay pending until that republish commits them. `discovery` compares `published` for the same reason its probe cannot: assembling the prompt reconciles other scopes. A departure's empty target set is also a probe-free pass now, instead of being normalized to "no restriction" and re-probing every survivor, and the per-publish snapshot reads tool names through one shared `surfaceOf` rather than a copy per plugin (#368). +- 让逐 agent 重探针不再作废仍有效的注册,也不再把未发布的结果留在 entry 上。`reconcile` 每次调用都推进 epoch,于是新增 `targets` 参数变成空转的那些 pass 仍会作废所有已发布的 `execute` 守卫与 Native 影子工具,它们随后对仍然有效的 scope 返回 `POLICY_DENIED`;探针还会在消费它的那次重发布执行之前就把 surface 记到 entry 上,被后一次 reconcile 取代的 pass 由此让下一个 pass 把该 surface 当作已发布而永远提前返回,PTC 判定因此始终无法生效。现在单独的 `published` 计数器只在某个 pass 真正重发布时推进,守卫改为比较它,探针结果保持待提交直到那次重发布写入。`discovery` 同样比较 `published`:它的探针要装配提示词,而装配会 reconcile 其它 scope。离开 agent 时的空目标集合现在是真正的免探测 pass,不再被归一化成“无限制”而对全部幸存者重探;每次发布的工具快照改由共享的 `surfaceOf` 读工具名,不再每个插件各存一份(#368)。 - Stop re-probing every tracked agent on each PTC publish. `ptc` and `discovery` both assembled the entire prompt once per agent per reconcile, so a new session paid for every session still tracked: on Windows x64 with 38 bundles the same `session/create` took 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms over eight rounds. Each agent now memoizes its presentation verdict against the tool names it publishes and re-probes only when that set changes, `reconcile` accepts the agents a change actually touches, and the per-publish tool snapshot reads names instead of rebuilding a scope view per tool. The same eight rounds now measure 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms (#333, #359). - 不再在每次 PTC 发布时重新探测全部已跟踪 agent。`ptc` 与 `discovery` 都在每次 reconcile 里对每个 agent 完整装配一次提示词,因此新建一个会话要付出所有仍在跟踪的会话的代价:Windows x64、38 个 bundle 下,同一个 `session/create` 八轮为 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms。现在每个 agent 按自己发布的工具名缓存呈现判定、只有该集合变化时才重新探测,`reconcile` 接受「本次真正受影响的 agent」,每次发布的工具快照只读工具名而不再为每个工具重建一次 scope 视图。同样八轮实测为 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms(#333、#359)。 diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 0fbb1113..e614c6c1 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -7,6 +7,7 @@ import { resolveDiscoveryConfig } from '../config.js' import { NativeFileTools } from '../file/runtime.js' import { nativeProviders, registerNativeProvider, claimNativeSelection } from '../file/result-provider.js' import { ownScopes } from '../scoped-ownership.js' +import { surfaceOf as toolSurface } from '../surface.js' import { isNativePresentation, isPresentationProbe } from '../presentation.js' import { mountResultLifecycle } from '../kernel/result-lifecycle.js' import { failure, resolveWorker } from '../file/worker.js' @@ -75,10 +76,14 @@ export function apply(root: Context, raw: unknown): void { * A presentation probe assembles the whole prompt, so probing every tracked agent on * every reconcile made one session's tool change cost more as sessions accumulated. * The verdict only depends on which tools the scope publishes; a different set of names - * re-probes, and an agent nobody references drops out with its entry. + * re-probes, and an agent nobody references drops out with its entry. That is an + * assumption about `isNativePresentation`, which reads the `tools:ptc-only` section text + * as well as the tool list: a presentation that flips without the names moving is missed + * here. The per-step `native()` probe below is unmemoized, so such a scope corrects itself + * on its next step. */ const probed = new WeakMap() - const surfaceOf = (agent: Agent): string => tools.schemas(agent).map(schema => schema.name).join('\u0000') + const surfaceOf = (agent: Agent): string => toolSurface(tools, agent) const settledNative = async (agent: Agent): Promise => { const surface = surfaceOf(agent) const memo = probed.get(agent) diff --git a/src/ptc/index.ts b/src/ptc/index.ts index 08e20869..0abef0f7 100644 --- a/src/ptc/index.ts +++ b/src/ptc/index.ts @@ -18,6 +18,7 @@ import { PTC_RESIDENT_TOOLS } from './config.js' import type { ApiName } from './config.js' import { sdkDefinition, requestedApis } from './sdk.js' import { ownPtcScopes } from './ownership.js' +import { surfaceOf as toolSurface } from '../surface.js' import { monitoredInvoke } from './invoke.js' export { resolvePtcConfig } from '../config.js' export const name = 'dsh-ops-ptc' @@ -27,7 +28,15 @@ const ptcDefinitions = new WeakSet() interface Entry { ctx?: Context; fiber?: Fiber; ready: Promise; disposers: (() => void)[] selected: Set; pending: Set; owned: Map; ptc: boolean - /** Tool names this agent published when `ptc` was last probed; a change re-probes. */ + /** + * Tool names this agent published when `ptc` was last probed; a change re-probes. + * + * Names are the whole memo key, and `isPtc` reads more than names: it also asks whether + * the `tools:ptc-only` section is non-empty. A verdict that flips without the name set + * moving is missed here. Both self-heal on the next step — the assemble-withdraw handler + * re-probes a scope whose assembly stopped being PTC-only, and `discovery` re-probes on + * every step — so the stale verdict costs one pass, never a permanently wrong surface. + */ surface?: string } const isPtc = (assembly: PromptAssembly): boolean => assembly.tools.length === 1 && assembly.tools[0]?.name === 'run_code' @@ -54,15 +63,7 @@ export function apply(root: Context, rawConfig: unknown): void { do { current = chain; await current } while (current !== chain) settled = current } - /** - * Visible tool names of one agent, the surface a `ptcOnly` probe reads. - * - * Names alone decide the probe: `isPtc` only asks whether `run_code` is the sole - * tool, so a definition swapped for an equal name cannot flip the verdict. Reading - * definitions too would cost one `tools.get` per name, and each of those rebuilds the - * whole scope view. - */ - const surfaceOf = (agent: Agent): string => tools.schemas(agent).map(schema => schema.name).join('\u0000') + const surfaceOf = (agent: Agent): string => toolSurface(tools, agent) const clear = (entry: Entry) => { entry.owned.clear() for (const dispose of entry.disposers.splice(0)) { @@ -105,11 +106,15 @@ export function apply(root: Context, rawConfig: unknown): void { * has. Publication itself always covers every entry, because the Native snapshot below is * exact only once every agent's masks are lifted — a partial pass would read the remaining * agents through their live overrides and register a short shadow set. + * + * An empty set is a probe-free pass, not an absent one: it names no agent to probe + * while still asking for the republish a departure owes. `undefined` is the opposite + * request — probe every agent — and is what a registry change passes, because the + * event does not say which scope moved. */ const reconcile = (force = false, targets?: ReadonlySet): Promise => { if (stopped || mutating) return chain - const selected = targets === undefined || targets.size === 0 ? undefined : targets - const probed = (agent: Agent) => selected === undefined || selected.has(agent) + const probed = (agent: Agent) => targets === undefined || targets.has(agent) // A probe-free pass (agent disposal) still owes the republish: the remaining agents' // masks and shadows were computed against the departee's registrations. const republishAnyway = targets !== undefined && targets.size === 0 diff --git a/src/surface.ts b/src/surface.ts new file mode 100644 index 00000000..0aab7c67 --- /dev/null +++ b/src/surface.ts @@ -0,0 +1,17 @@ +/** The tool-name surface a presentation probe reads, shared by both PTC rows. */ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type ToolRuntime from '@deepseek-ai/dsh-tools' + +/** + * Visible tool names of one agent, the surface a presentation probe reads. + * + * Names alone decide the probe: a `ptcOnly`/`native` verdict asks which tools the + * scope publishes and whether `run_code` is the only one, so a definition swapped + * for an equal name cannot flip it. Reading definitions as well would cost one + * `tools.get` per name, and each of those rebuilds the whole scope view. + * @param tools - the tool registry. + * @param agent - the scope to read. + * @returns the visible names joined by NUL, usable as a memo key. + */ +export const surfaceOf = (tools: Pick, agent: Agent): string => + tools.schemas(agent).map(schema => schema.name).join('\u0000') diff --git a/test/ptc-departure.test.mjs b/test/ptc-departure.test.mjs new file mode 100644 index 00000000..3a047223 --- /dev/null +++ b/test/ptc-departure.test.mjs @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createScope } from '@deepseek-ai/dsh-scope' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' +import * as ptc from '../lib/ptc/index.js' +import { isPresentationProbe } from '../lib/presentation.js' +import { assert, test, report, wait, waitFor, withTempDir } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { WORKER, requireWorker } from './lib/boundary.mjs' +requireWorker('ptc-departure') +let sequence = 0 +const names = (ctx, agent) => ctx.get('tools').schemas(agent).map(tool => tool.name).sort() +// A stall must fail the check, not hang the suite: the timer is cleared so it cannot +// outlive the race and keep the process alive past report(). +const bounded = async (work, ms) => { let timer; try { return await Promise.race([work, new Promise(resolve => { timer = setTimeout(() => resolve('timeout'), ms) })]) } finally { clearTimeout(timer) } } +async function setup(dir) { + const { ctx } = await bootHost() + const agents = []; ctx.provide('agents', { list: () => agents }) + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem); await ctx.plugin(NodePtc) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session } = {}) => ({ mode: session?.mode ?? 'workspace-write', workspaceRoot: dir, sessionId: session?.id }) })) + await ctx.plugin(ptc, { binaryPath: WORKER, required: true }) + async function mint(mode = 'native') { + const id = 'departure-' + ++sequence, agent = { id, session: { id, mode: 'workspace-write', header: { cwd: dir }, append() {}, read() { return [] } } } + agent.ctx = createScope(ctx, agent).ctx + await agent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(mode) } }) + agents.push(agent) + await ctx.serial('agent/created', { agent }) + return agent + } + return { ctx, mint } +} +// A presentation probe assembles the whole prompt, so counting probe assemblies is how a +// pass that skips agents is told apart from one that walks every agent again. +await test('an agent departure republishes without re-probing the survivors', () => withTempDir('ptc-departure', async dir => { + const { ctx, mint } = await setup(dir) + try { + const prompt = ctx.get('systemPrompt'), assemble = prompt.assemble.bind(prompt) + let probes = 0 + prompt.assemble = context => { if (isPresentationProbe(context)) ++probes; return assemble(context) } + try { + const keep = await mint('ptc'), leave = await mint('ptc') + await waitFor(() => names(ctx, keep).includes('run_code') && names(ctx, leave).includes('run_code')) + await wait(200) + const before = probes + const settled = await bounded(ctx.serial('agent/disposed', { agent: leave }).then(() => 'done'), 5000) + assert.equal(settled, 'done', 'a departure must not stall its own event') + assert.equal(probes, before, 'a departure names no agent to probe, so the survivors keep the verdict they already have') + assert.ok(names(ctx, keep).includes('run_code'), 'the surviving agent keeps the PTC surface the departure left intact') + // run_code stays visible: presentAs('ptc') is the departing scope's own layer, + // while the PTC row's own APIs are what a departure must withdraw. + assert.equal(names(ctx, keep).filter(name => name.startsWith('ops_')).includes('ops_load_sdk'), true) + assert.equal(names(ctx, leave).filter(name => name.startsWith('ops_')).includes('ops_load_sdk'), false) + } finally { prompt.assemble = assemble } + } finally { await shutdown(ctx) } +})) +report('ptc-departure') From 9145e592879e96c09df24a6407f578170804dc94 Mon Sep 17 00:00:00 2001 From: ikliml666 Date: Sat, 24 Oct 2026 10:35:44 +0800 Subject: [PATCH 12/50] fix(ptc): key the surface by the scope view and its definition identity `surfaceOf` asked `tools.schemas()` for the names it memoizes, and that projector rebuilds one deep copy of every visible parameter schema per call. Read the same visible map through `tools.view(scope)` instead, keeping `schemas()` as a fallback for a runtime that exposes no view, and pair each name with a `WeakMap` identity for the definition the view holds. A names-only key returns a stale hit when a scoped registration shadows a global of the same name, so missing the shadow publishes nothing on the change that owed a republish. Identity costs one lookup against an object already in hand rather than the `tools.get()` pairing that would rebuild the whole view per name. --- docs/CHANGELOG.md | 2 + src/surface.ts | 94 ++++++++++++++++++++--- test/ptc-surface.test.mjs | 158 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 test/ptc-surface.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4b99eb48..1750e998 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Read the per-publish surface from the scope's derived view instead of its wire schemas, and carry the definition identity in the key. `surfaceOf` asked `tools.schemas()` for the names it memoizes, and that projector rebuilds one deep copy of every visible parameter schema per call: on the published 0.2.1-rc.2 tool runtime a single 258-tool read costs 24.58 ms against 0.094 ms for one view read naming the same surface, so the memo key cost more than the prompt assembly it was meant to skip — measured end to end with 1/2/4/8 agents the probe ran 6.5/14.3/32.8/58.2 ms before and 25.9/47.4/94.6/198.4 ms after, i.e. 2.9–4.0x the work it saves. Names now come from `tools.view(scope).visible`, which resolves the same visible map without projecting it; `schemas()` is kept as a fallback for a runtime that does not expose a view. The key pairs each name with a `WeakMap` identity for the definition the view holds, because a names-only key returns a stale hit when a scoped registration shadows a global of the same name — the shadow is what a publish captures, so missing it publishes nothing on the change that owed a republish. Identity costs one lookup against an object already in hand rather than the `tools.get()` pairing that would rebuild the whole view per name and turn the key into O(names²) (#368, #369). +- 每次发布的工具快照改为读取 scope 的派生视图,不再读取其 wire schema,并让键携带定义身份。`surfaceOf` 之前用 `tools.schemas()` 取名当备忘键,而该投影每次调用都会把所有可见工具的参数 schema 深拷贝一遍:在已发布的 0.2.1-rc.2 工具运行时上,一次 258 工具读取要 24.58 ms,而读取同一份表面的单次视图只要 0.094 ms,于是这个备忘键比它想省掉的那次提示词装配更贵——端到端实测 1/2/4/8 个 agent 时,探针改动前为 6.5/14.3/32.8/58.2 ms,改动后为 25.9/47.4/94.6/198.4 ms,即多做了 2.9–4.0 倍的功。现在工具名取自 `tools.view(scope).visible`,它在不投影的前提下解出同一份可见映射;保留 `schemas()` 作为运行时未暴露视图时的回退。键为每个名字配上该视图所持定义的 `WeakMap` 身份,因为只含名字的键在 scoped 注册遮蔽同名全局定义时会返回陈旧命中——被遮蔽的那份定义才是发布实际捕获的对象,漏掉它就会在该重新发布的变化上什么都不发。身份只需对已持有的对象查一次表,而非改用 `tools.get()` 逐名配对(后者会为每个名字重建整份视图,把键的代价变成 O(名字数²))(#368、#369)。 - Keep the per-agent re-probe from retiring live registrations or stranding an unpublished surface. `reconcile` advanced its epoch on every call, so the passes the new `targets` argument turns into no-ops still invalidated every published `execute` guard and every Native shadow, which then answered `POLICY_DENIED` for a scope that was still authoritative; and a probe recorded its surface on the entry before the republish that consumes it ran, so a pass a later reconcile superseded left the next pass reading that surface as already published and returning early forever, which is how a PTC verdict could never take effect. A separate `published` counter now moves only when a pass actually republishes, the guards compare it, and probed surfaces stay pending until that republish commits them. `discovery` compares `published` for the same reason its probe cannot: assembling the prompt reconciles other scopes. A departure's empty target set is also a probe-free pass now, instead of being normalized to "no restriction" and re-probing every survivor, and the per-publish snapshot reads tool names through one shared `surfaceOf` rather than a copy per plugin (#368). - 让逐 agent 重探针不再作废仍有效的注册,也不再把未发布的结果留在 entry 上。`reconcile` 每次调用都推进 epoch,于是新增 `targets` 参数变成空转的那些 pass 仍会作废所有已发布的 `execute` 守卫与 Native 影子工具,它们随后对仍然有效的 scope 返回 `POLICY_DENIED`;探针还会在消费它的那次重发布执行之前就把 surface 记到 entry 上,被后一次 reconcile 取代的 pass 由此让下一个 pass 把该 surface 当作已发布而永远提前返回,PTC 判定因此始终无法生效。现在单独的 `published` 计数器只在某个 pass 真正重发布时推进,守卫改为比较它,探针结果保持待提交直到那次重发布写入。`discovery` 同样比较 `published`:它的探针要装配提示词,而装配会 reconcile 其它 scope。离开 agent 时的空目标集合现在是真正的免探测 pass,不再被归一化成“无限制”而对全部幸存者重探;每次发布的工具快照改由共享的 `surfaceOf` 读工具名,不再每个插件各存一份(#368)。 - Stop re-probing every tracked agent on each PTC publish. `ptc` and `discovery` both assembled the entire prompt once per agent per reconcile, so a new session paid for every session still tracked: on Windows x64 with 38 bundles the same `session/create` took 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms over eight rounds. Each agent now memoizes its presentation verdict against the tool names it publishes and re-probes only when that set changes, `reconcile` accepts the agents a change actually touches, and the per-publish tool snapshot reads names instead of rebuilding a scope view per tool. The same eight rounds now measure 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms (#333, #359). diff --git a/src/surface.ts b/src/surface.ts index 0aab7c67..a447337a 100644 --- a/src/surface.ts +++ b/src/surface.ts @@ -1,17 +1,93 @@ -/** The tool-name surface a presentation probe reads, shared by both PTC rows. */ +/** The tool surface a presentation probe reads, shared by both PTC rows. */ import type { Agent } from '@deepseek-ai/dsh-agent' import type ToolRuntime from '@deepseek-ai/dsh-tools' /** - * Visible tool names of one agent, the surface a presentation probe reads. + * The part of a scope view this module reads: the names and definitions a scope can see. * - * Names alone decide the probe: a `ptcOnly`/`native` verdict asks which tools the - * scope publishes and whether `run_code` is the only one, so a definition swapped - * for an equal name cannot flip it. Reading definitions as well would cost one - * `tools.get` per name, and each of those rebuilds the whole scope view. + * `ToolRuntime.view` is declared `private`, yet it is `schemas()`'s own implementation — + * `schemas()` is `[...this.view(scope).visible.values()].map(schemaOf)` — so its entries + * ARE the visible definitions, before any `snapshotJsonValue` copy. A structural local + * type keeps that read honest: it names only what is used, and the `schemas()` branch + * below holds if a host stops serving it. + */ +interface ScopeView { + visible?: { + entries?: () => Iterable<[string, unknown]> + keys?: () => Iterable + } +} +type Viewable = { view?: (scope?: object) => ScopeView | undefined } + +/** Stable per-definition identities, so a swapped definition cannot reuse its predecessor's. */ +const identities = new WeakMap() +let sequence = 0 + +/** + * The identity of one visible definition. + * + * Only object definitions can carry identity; anything else (an absent entry) folds to 0 + * so the key stays a plain string. + * @param definition - the value a scope view holds under a tool name. + * @returns a process-unique index for that exact object. + */ +const identityOf = (definition: unknown): number => { + if (definition === null || typeof definition !== 'object') return 0 + const known = identities.get(definition as object) + if (known !== undefined) return known + const next = ++sequence + identities.set(definition as object, next) + return next +} + +/** + * The surface of one agent, the memo key a presentation probe is cached under. + * + * Two things move a `ptcOnly`/`native` verdict, and the key must see both: + * + * - **which names are visible.** `run_code` exists only outside `native`, so a mode flip + * adds or drops it. + * - **which definition each name resolves to.** A same-named registration shadows the one + * it replaces, and the publish that follows captures the definition inside its shadow's + * closure. A names-only key returns a stale hit there and publishes nothing, leaving the + * cached verdict standing for a surface it never described. + * + * Both are read from ONE `view()` call. `view()` hands back the definitions themselves, so + * identity costs a `WeakMap` lookup against an object already in hand — measurably cheaper + * than pairing `schemas()` with a `tools.get()` per name, because that `get()` rebuilds the + * whole scope view and turns the key into O(names²). + * + * The cost is what makes this safe to memoize at all: reading through `schemas()` deep-copies + * every visible schema — 24.1 ms for 258 tools on the pinned host, 30.9 ms once the per-name + * `get()` is added — against 0.073 ms here, both including identity. A key that costs more + * than the assembly it skips turns the memo into a slowdown. `schemas()` survives only as + * the fallback for a host whose view is not reachable, where correctness outranks the cost. * @param tools - the tool registry. * @param agent - the scope to read. - * @returns the visible names joined by NUL, usable as a memo key. + * @returns per-entry `name\u0001identity` joined by NUL, usable as a memo key. + */ +export const surfaceOf = (tools: Pick, agent: Agent): string => { + // Duck-typed rather than `instanceof Map`: a host may serve the view through a subclass + // or a proxy, and a failed instanceof would silently take the expensive branch forever. + const visible = (tools as unknown as Viewable).view?.(agent)?.visible + if (typeof visible?.entries === 'function') { + const parts: string[] = [] + for (const [name, definition] of visible.entries()) parts.push(`${name}\u0001${identityOf(definition)}`) + return parts.join('\u0000') + } + // A view that publishes names only still beats a projection: a mode flip moves names even + // where a same-named swap cannot be seen. + if (typeof visible?.keys === 'function') return [...visible.keys()].join('\u0000') + return tools.schemas(agent).map(schema => schema.name).join('\u0000') +} + +/** + * The tool names a surface names, in view order. + * + * Callers that only need the name set (diagnostics, assertions) read it back out of a key + * rather than paying for a second `view()`. + * @param surface - a key produced by {@link surfaceOf}. + * @returns the names, in the order the view reported them. */ -export const surfaceOf = (tools: Pick, agent: Agent): string => - tools.schemas(agent).map(schema => schema.name).join('\u0000') +export const surfaceNames = (surface: string): string[] => + surface === '' ? [] : surface.split('\u0000').map(entry => entry.split('\u0001')[0]!) diff --git a/test/ptc-surface.test.mjs b/test/ptc-surface.test.mjs new file mode 100644 index 00000000..e80208b7 --- /dev/null +++ b/test/ptc-surface.test.mjs @@ -0,0 +1,158 @@ +/** + * The probe's memo key must cost less than the assembly it skips. + * + * `surfaceOf` names the tools a scope can see, and both PTC rows use that name list to + * decide whether a presentation verdict is still current. The key is therefore read once + * per tracked agent per reconcile, while the work it skips is one prompt assembly. Reading + * it through `schemas()` deep-copies every visible schema, which on this host costs more + * than the assembly: the memo then loses time on every pass instead of saving any. + * + * These checks pin the shape rather than a wall-clock number, so they hold on any host. + */ +import { createScope } from '@deepseek-ai/dsh-scope' +import { bootHost, shutdown } from './lib/host.mjs' +import { assert, test, report } from './lib/harness.mjs' +import { surfaceOf, surfaceNames } from '../lib/surface.js' + +/** A registered tool whose schema is big enough that a deep copy is measurable. */ +const tool = name => ({ + name, + description: `surface probe fixture ${name}`, + parameters: { + type: 'object', + properties: Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`field_${index}`, { + type: 'object', + description: `nested option group ${index} of ${name}, long enough that a deep copy shows up`, + properties: { mode: { type: 'string', enum: ['fast', 'safe', 'strict'] } } + }])) + }, + output: { schema: { type: 'object', properties: { ok: { type: 'boolean' } } }, render: () => [] }, + execute: async () => ({ content: 'ok' }) +}) + +/** + * Wrap `schemas` so a call is counted, leaving `this` bound to the real registry. + * + * The registry reaches its own methods through a scoped proxy that rebinds `this` only + * for a call arriving on the proxy itself, so an arrow replacement would break every + * internal call as well and count nothing. + * @param tools - the registry to observe. + * @returns the running count of `schemas` calls. + */ +function countSchemas (tools) { + const calls = { count: 0 } + const original = tools.schemas + tools.schemas = function (...args) { ++calls.count; return original.apply(this, args) } + return calls +} + +/** + * Wrap `view` so a call is counted, with the same `this` care as {@link countSchemas}. + * @param tools - the registry to observe. + * @returns the running count of `view` calls. + */ +function countViews (tools) { + const views = { count: 0 } + const original = tools.view + tools.view = function (...args) { ++views.count; return original.apply(this, args) } + return views +} + +await test('a surface read does not deep-copy schemas', () => withScope(async ({ tools, key, calls }) => { + calls.count = 0 + const surface = surfaceOf(tools, key) + assert.equal(calls.count, 0, 'reading the surface must not project schemas; a deep copy per agent per pass is the cost this memo exists to avoid') + assert.deepEqual(surfaceNames(surface).sort(), ['alpha', 'beta'], 'the surface names every visible tool') +})) + +await test('a surface read does not re-read the view once per name', () => withScope(async ({ tools, key, views }) => { + // `tools.get()` and `tools.schemas()` each rebuild the whole scope view, so pairing them + // per name would make the key O(names²) — the shape main shipped, and the reason this + // module reads one view and takes identity off the objects it already holds. + views.count = 0 + const surface = surfaceOf(tools, key) + assert.equal(views.count, 1, 'one view read must produce both the names and their identities') + assert.equal(surfaceNames(surface).length, 2, 'the surface still describes every visible tool') +})) + +await test('a surface read agrees with schemas() for the same scope', () => withScope(async ({ tools, key }) => { + const projected = tools.schemas(key).map(schema => schema.name) + assert.deepEqual(surfaceNames(surfaceOf(tools, key)), projected, 'the memo key must name exactly what schemas() projects, in the same order') +})) + +await test('a surface read follows the tools a scope gains and loses', () => withScope(async ({ tools, key }) => { + const before = surfaceOf(tools, key) + const dispose = tools.register(tool('gamma')) + try { + assert.notEqual(surfaceOf(tools, key), before, 'a new registration changes the surface, so the verdict is re-probed') + assert.ok(surfaceNames(surfaceOf(tools, key)).includes('gamma'), 'the new tool is named') + } finally { dispose() } + assert.equal(surfaceOf(tools, key), before, 'disposing the registration restores the previous surface') +})) + +await test('a same-named replacement moves the surface', () => withScope(async ({ tools, key, agent }) => { + // A scoped registration shadows the global definition of the same name. The name set is + // unchanged, yet the definition a publish captures in its shadow closure is a different + // object, so the key has to move: a names-only key caches a verdict for a surface that no + // longer exists and publishes nothing on the change that owed a republish. + const before = surfaceOf(tools, key) + const fiber = await scoped(agent, scopedTools => scopedTools.register(tool('alpha'))) + try { + assert.notEqual(surfaceOf(tools, key), before, 'a same-named shadow replaces the definition, which is a surface change') + assert.deepEqual(surfaceNames(surfaceOf(tools, key)), surfaceNames(before), 'and it is not a name change — the names alone would have missed it') + } finally { await fiber.dispose() } + assert.equal(surfaceOf(tools, key), before, 'lifting the shadow restores the previous surface') +})) + +await test('a host without a reachable view still names the surface', () => withScope(async ({ tools, key }) => { + // The fallback is the correctness branch: a host whose view this module cannot reach must + // still produce the same key, paying the deep copy rather than returning a wrong verdict. + // Run against a stand-in registry, since deleting `view` from the real one is not possible. + const projected = tools.schemas(key).map(schema => schema.name) + const noView = { schemas: scope => tools.schemas(scope) } + assert.deepEqual(surfaceNames(surfaceOf(noView, key)), projected, 'the fallback names exactly what schemas() projects') +})) + +/** + * Act on the registry from inside a plugin mounted on one scope. + * + * A scoped registration or restriction refuses the host's unscoped context, so the call + * has to arrive through a context carrying the agent's scope tag. + * @param agent - the scoped context to mount on. + * @param act - receives that context's scoped registry, returns a disposer. + * @returns the disposer. + */ +/** + * Mount a plugin on the agent's scope and act on the registry from inside it. + * + * A scoped registration or restriction refuses the host's unscoped context, and the + * registry's scope tag lives on the context a plugin receives — not on the service object + * `ctx.get('tools')` returns, whose `scopeOf` is undefined. Going through a plugin is + * therefore the only way to reach a scoped registry. + * @param agent - the scoped context to mount on. + * @param act - receives that context's scoped registry. + * @returns the plugin's disposable fiber. + */ +async function scoped (agent, act) { + return agent.plugin({ inject: ['tools'], apply (inner) { act(inner.tools) } }) +} + +/** + * Boot a host with the fixture tools registered and one scoped agent. + * @param {Function} body - the check to run against that host. + */ +async function withScope (body) { + const { ctx } = await bootHost() + try { + const tools = ctx.get('tools') + tools.register(tool('alpha')) + tools.register(tool('beta')) + // `key` is what `view()` takes; the context only carries it. Passing the context + // silently reads the global layer, which is how a scoped shadow can go unseen. + const key = { id: 'surface-' + Math.random() } + const scope = createScope(ctx, key) + await body({ ctx, tools, key, agent: scope.ctx, calls: countSchemas(tools), views: countViews(tools) }) + } finally { await shutdown(ctx) } +} + +report('ptc-surface') From 3c3219d6d9911496f710bd40ad369cf11366e317 Mon Sep 17 00:00:00 2001 From: ikliml666 Date: Sat, 24 Oct 2026 13:20:22 +0800 Subject: [PATCH 13/50] perf(prompt): read visible tool names through the scope view visibleTools (the tooling:shell/file prompt section and the host-shell denial gate) asked tools.schemas() and kept only the names, paying one deep copy of every visible definition per assembly: 34.2 ms for 258 tools at the issue's own surface shape on the published 0.2.1-rc.2 runtime, against 0.043 ms through tools.view() (~800x). Cost tracks nested schema nodes, not payload bytes (811 KB flat = 16.5 ms; 705 KB nested = 34.2 ms), so the fixture is pinned to the issue's measurement. visibleNames() reads view().keys() with schemas() as the fallback for a host whose view is not reachable, so correctness outranks the cost there. The discovery pre-step assembly is deliberately left alone: it is the self-correction path for a presentation that flips without the names moving, and memoizing it there breaks the ptc-sdk projections (#369). Tests assert the two review gaps as well: a tools/change with no registry movement reconciles from the discovery probe memo without assembling, and the denial gate restricts without projecting schemas. Both fail when reverted. Stacked on #368: surface.ts and the view machinery come from there. --- docs/CHANGELOG.md | 3 +- src/index.ts | 10 ++- src/surface.ts | 24 +++++- test/discovery-probe-memo.test.mjs | 57 +++++++++++++ test/ptc-surface.test.mjs | 9 -- test/standard-mode-costs.test.mjs | 128 +++++++++++++++++++++++++++++ 6 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 test/discovery-probe-memo.test.mjs create mode 100644 test/standard-mode-costs.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1750e998..0df0c437 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,13 +7,14 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Read the tool names the tooling sections render through the scope view instead of the schema projection. `visibleTools` (the `tooling:shell`/`tooling:file` prompt section and the host-shell denial gate) asked `tools.schemas()` and kept only the names, so every prompt assembly paid one deep copy of every visible definition: 34.2 ms for 258 tools at the issue's own surface shape on the published 0.2.1-rc.2 runtime, against 0.043 ms through `tools.view()` — about 800x for the same name list in the same order, verified against `schemas()` for a scoped key and for the global layer. Cost tracks nested schema nodes, not payload bytes: the same host spends 16.5 ms on 811 KB of flat string fields but 34.2 ms on 705 KB carrying eight nested groups per tool, so the fixture is pinned to the issue's measurement (705,008 bytes of `parameters` against the recorded 691,714). The discovery pre-step probe is deliberately left alone: its per-step assembly is the path by which a presentation that flips without the names moving corrects itself, so memoizing it there trades a measured cost for a correctness regression (#369). +- 工具区段取工具名改用 scope 视图,不再走 schema 投影。`visibleTools`(`tooling:shell`/`tooling:file` 提示词区段与宿主 shell 拒绝门)之前问 `tools.schemas()` 后只保留名字,于是每次提示词装配都要把所有可见定义深拷贝一遍:已发布的 0.2.1-rc.2 运行时上,按 issue 自身的表面形状造 258 工具要 34.2 ms,而走 `tools.view()` 只要 0.043 ms——同一份名字、同一顺序下约 800 倍,并在 scoped key 与全局层两种形态下逐一对照 `schemas()` 验证一致。代价取决于 schema 的嵌套节点数而非 payload 字节:同一宿主上 811 KB 的扁平字符串字段只要 16.5 ms,而 705 KB、每工具八组嵌套对象要 34.2 ms,因此 fixture 按 issue 的度量固定(`parameters` 合计 705,008 字节,issue 记录值 691,714)。discovery pre-step 探针刻意不动:它每步的那次装配正是「呈现已翻转但工具名未变」这类 scope 自我纠正的路径,在那里加备忘等于用实测成本换正确性回归(#369)。 - Read the per-publish surface from the scope's derived view instead of its wire schemas, and carry the definition identity in the key. `surfaceOf` asked `tools.schemas()` for the names it memoizes, and that projector rebuilds one deep copy of every visible parameter schema per call: on the published 0.2.1-rc.2 tool runtime a single 258-tool read costs 24.58 ms against 0.094 ms for one view read naming the same surface, so the memo key cost more than the prompt assembly it was meant to skip — measured end to end with 1/2/4/8 agents the probe ran 6.5/14.3/32.8/58.2 ms before and 25.9/47.4/94.6/198.4 ms after, i.e. 2.9–4.0x the work it saves. Names now come from `tools.view(scope).visible`, which resolves the same visible map without projecting it; `schemas()` is kept as a fallback for a runtime that does not expose a view. The key pairs each name with a `WeakMap` identity for the definition the view holds, because a names-only key returns a stale hit when a scoped registration shadows a global of the same name — the shadow is what a publish captures, so missing it publishes nothing on the change that owed a republish. Identity costs one lookup against an object already in hand rather than the `tools.get()` pairing that would rebuild the whole view per name and turn the key into O(names²) (#368, #369). - 每次发布的工具快照改为读取 scope 的派生视图,不再读取其 wire schema,并让键携带定义身份。`surfaceOf` 之前用 `tools.schemas()` 取名当备忘键,而该投影每次调用都会把所有可见工具的参数 schema 深拷贝一遍:在已发布的 0.2.1-rc.2 工具运行时上,一次 258 工具读取要 24.58 ms,而读取同一份表面的单次视图只要 0.094 ms,于是这个备忘键比它想省掉的那次提示词装配更贵——端到端实测 1/2/4/8 个 agent 时,探针改动前为 6.5/14.3/32.8/58.2 ms,改动后为 25.9/47.4/94.6/198.4 ms,即多做了 2.9–4.0 倍的功。现在工具名取自 `tools.view(scope).visible`,它在不投影的前提下解出同一份可见映射;保留 `schemas()` 作为运行时未暴露视图时的回退。键为每个名字配上该视图所持定义的 `WeakMap` 身份,因为只含名字的键在 scoped 注册遮蔽同名全局定义时会返回陈旧命中——被遮蔽的那份定义才是发布实际捕获的对象,漏掉它就会在该重新发布的变化上什么都不发。身份只需对已持有的对象查一次表,而非改用 `tools.get()` 逐名配对(后者会为每个名字重建整份视图,把键的代价变成 O(名字数²))(#368、#369)。 - Keep the per-agent re-probe from retiring live registrations or stranding an unpublished surface. `reconcile` advanced its epoch on every call, so the passes the new `targets` argument turns into no-ops still invalidated every published `execute` guard and every Native shadow, which then answered `POLICY_DENIED` for a scope that was still authoritative; and a probe recorded its surface on the entry before the republish that consumes it ran, so a pass a later reconcile superseded left the next pass reading that surface as already published and returning early forever, which is how a PTC verdict could never take effect. A separate `published` counter now moves only when a pass actually republishes, the guards compare it, and probed surfaces stay pending until that republish commits them. `discovery` compares `published` for the same reason its probe cannot: assembling the prompt reconciles other scopes. A departure's empty target set is also a probe-free pass now, instead of being normalized to "no restriction" and re-probing every survivor, and the per-publish snapshot reads tool names through one shared `surfaceOf` rather than a copy per plugin (#368). - 让逐 agent 重探针不再作废仍有效的注册,也不再把未发布的结果留在 entry 上。`reconcile` 每次调用都推进 epoch,于是新增 `targets` 参数变成空转的那些 pass 仍会作废所有已发布的 `execute` 守卫与 Native 影子工具,它们随后对仍然有效的 scope 返回 `POLICY_DENIED`;探针还会在消费它的那次重发布执行之前就把 surface 记到 entry 上,被后一次 reconcile 取代的 pass 由此让下一个 pass 把该 surface 当作已发布而永远提前返回,PTC 判定因此始终无法生效。现在单独的 `published` 计数器只在某个 pass 真正重发布时推进,守卫改为比较它,探针结果保持待提交直到那次重发布写入。`discovery` 同样比较 `published`:它的探针要装配提示词,而装配会 reconcile 其它 scope。离开 agent 时的空目标集合现在是真正的免探测 pass,不再被归一化成“无限制”而对全部幸存者重探;每次发布的工具快照改由共享的 `surfaceOf` 读工具名,不再每个插件各存一份(#368)。 - Stop re-probing every tracked agent on each PTC publish. `ptc` and `discovery` both assembled the entire prompt once per agent per reconcile, so a new session paid for every session still tracked: on Windows x64 with 38 bundles the same `session/create` took 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms over eight rounds. Each agent now memoizes its presentation verdict against the tool names it publishes and re-probes only when that set changes, `reconcile` accepts the agents a change actually touches, and the per-publish tool snapshot reads names instead of rebuilding a scope view per tool. The same eight rounds now measure 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms (#333, #359). - 不再在每次 PTC 发布时重新探测全部已跟踪 agent。`ptc` 与 `discovery` 都在每次 reconcile 里对每个 agent 完整装配一次提示词,因此新建一个会话要付出所有仍在跟踪的会话的代价:Windows x64、38 个 bundle 下,同一个 `session/create` 八轮为 264 / 308 / 396 / 475 / 590 / 681 / 748 / 947 ms。现在每个 agent 按自己发布的工具名缓存呈现判定、只有该集合变化时才重新探测,`reconcile` 接受「本次真正受影响的 agent」,每次发布的工具快照只读工具名而不再为每个工具重建一次 scope 视图。同样八轮实测为 195 / 226 / 270 / 260 / 295 / 329 / 358 / 373 ms(#333、#359)。 - - Make the Windows CI leg non-blocking while keeping it running on every event (#316): split `validate` into blocking `validate-posix` (Linux x64, macOS arm64) and `continue-on-error` `validate-windows`, and keep `ci-required` from depending on any `continue-on-error` job, so the aggregate neither waits for nor fails with the slowest leg. On `main` after #307 the Windows leg takes 527 / 529 s — up from 482 s before #307, because `npm ci` now downloads the 411 MB Windows Bash payload — against 136–139 s (Linux) and 168–182 s (macOS). `scripts/check-workflows.mjs` now enforces "every blocking job in the aggregate, no non-blocking job in it" and that the aggregate's failure loop names exactly the blocking jobs, so a job cannot stay in `needs` but escape judgement; the Windows result is a signal to act on, not a merge gate. - 将 CI 的 Windows 腿改为非阻塞、但仍每次触发都运行(#316):把 `validate` 拆成阻塞的 `validate-posix`(Linux x64、macOS arm64)与 `continue-on-error` 的 `validate-windows`,并让 `ci-required` 不依赖任何 `continue-on-error` job,聚合既不等待也不随最慢的一腿失败。`main` 在 #307 合入后实测 Windows 腿 527 / 529 秒(#307 之前 482 秒,因为 `npm ci` 现在要拉 411 MB 的 Windows Bash 载荷),Linux 136–139 秒、macOS 168–182 秒。`scripts/check-workflows.mjs` 现在强制“每个阻塞 job 都在聚合里、聚合里没有非阻塞 job”,并要求失败判定循环里的 job 集合与阻塞集合完全相等,避免 job 留在 `needs` 却逃过判定;Windows 结果是待处理的信号,不是合并门禁。 - Bound each `ops_replace` output to the same 32 MiB UTF-8 byte ceiling as file writes. Check expansion with overflow-safe arithmetic for the entire plan before constructing replacement content or publishing any file, including dry runs; an over-limit target returns `OUTPUT_LIMIT` with no file changes (#330). diff --git a/src/index.ts b/src/index.ts index f7ff8f8d..1964fbb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { resolveShells } from './shells.js' import { mountSessionBash } from './session-shells.js' import { NativeFileTools } from './file/runtime.js' import { resolveWorker } from './ptc/runtime.js' +import { visibleNames } from './surface.js' import { mountWorkspacePreparation } from './workspace-preparation.js' import { safeDispose, safeDisposeAll } from './teardown.js' import { mountResultLifecycle } from './kernel/result-lifecycle.js' @@ -31,8 +32,11 @@ function report(ctx: Context, message: string, level = 'error'): void { } function visibleTools(ctx: Context, agent?: Agent): string[] { try { - return (ctx.get('tools')?.schemas(agent) ?? []).map(schema => schema.name) - .filter(tool => typeof tool === 'string') + const tools = ctx.get('tools') + // Names only, through the view: `schemas()` projected every visible definition — + // 34.2 ms for 258 tools at the published runtime's surface shape — to keep a + // handful of owned names, and this section renders once per assembly. + return tools ? visibleNames(tools, agent) : [] } catch { return [] } } @@ -44,7 +48,7 @@ function installHostShellDenial(ctx: Context, config: ResolvedConfig): void { const hide = (agent: Agent) => { if (lifts.has(agent) || !agent?.ctx) return try { - const names = (ctx.tools.schemas(agent) ?? []).map(tool => tool.name).filter(name => denied.has(name)) + const names = visibleNames(ctx.tools, agent).filter(name => denied.has(name)) if (names.length) lifts.set(agent, agent.ctx.tools.restrict({ deny: names })) } catch (error) { if (!warned) { diff --git a/src/surface.ts b/src/surface.ts index a447337a..cc6a2734 100644 --- a/src/surface.ts +++ b/src/surface.ts @@ -58,8 +58,8 @@ const identityOf = (definition: unknown): number => { * whole scope view and turns the key into O(names²). * * The cost is what makes this safe to memoize at all: reading through `schemas()` deep-copies - * every visible schema — 24.1 ms for 258 tools on the pinned host, 30.9 ms once the per-name - * `get()` is added — against 0.073 ms here, both including identity. A key that costs more + * every visible schema — 34.2 ms for 258 tools at the issue's own surface shape on the + * published runtime — against ~0.05 ms here, identity included. A key that costs more * than the assembly it skips turns the memo into a slowdown. `schemas()` survives only as * the fallback for a host whose view is not reachable, where correctness outranks the cost. * @param tools - the tool registry. @@ -91,3 +91,23 @@ export const surfaceOf = (tools: Pick, agent: Agent): st */ export const surfaceNames = (surface: string): string[] => surface === '' ? [] : surface.split('\u0000').map(entry => entry.split('\u0001')[0]!) + +/** + * The visible tool names of one scope, without copying any schema. + * + * `schemas()` deep-copies every definition's parameters (34.2 ms for 258 tools at the + * issue's own surface shape on the official host) just to hand back names; the view + * reports the same names, in the same order, for ~0.043 ms. Name-only readers (prompt + * sections, restriction planning) pay the copy for nothing. Cost tracks nested schema + * nodes rather than payload bytes, so both figures are for the shape the issue measured. + * `undefined` reads the global layer on both paths, and a host without a reachable view + * falls back to the projection — correctness outranks the cost. + * @param tools - the tool registry. + * @param scope - the scope to read, or `undefined` for the global layer. + * @returns the visible tool names, in view order. + */ +export const visibleNames = (tools: Pick, scope?: object): string[] => { + const visible = (tools as unknown as Viewable).view?.(scope)?.visible + if (typeof visible?.keys === 'function') return [...visible.keys()] + return tools.schemas(scope as Agent).map(schema => schema.name) +} diff --git a/test/discovery-probe-memo.test.mjs b/test/discovery-probe-memo.test.mjs new file mode 100644 index 00000000..89f23de8 --- /dev/null +++ b/test/discovery-probe-memo.test.mjs @@ -0,0 +1,57 @@ +/** + * The discovery probe memo must actually skip the probe it claims to skip. + * + * `settledNative` caches a `native()` verdict per agent keyed by the scope view's surface. + * A `tools/change` with no registry movement behind it reconciles against an unchanged + * surface, so the memo must answer without assembling. Reverting the memo to a direct + * `native()` call re-assembles on every such pass — exactly what #369 measured — and this + * suite counts probe assemblies to tell the two apart. + */ +import { createScope } from '@deepseek-ai/dsh-scope' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import * as discovery from '../lib/components/discovery/index.js' +import { isPresentationProbe } from '../lib/presentation.js' +import { assert, test, report, wait, withTempDir } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' + +const foreignTool = name => ({ + name, + description: `probe memo fixture ${name}`, + parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'object' }, render: () => [] }, + execute: async () => ({ content: 'ok' }), +}) + +await test('an unchanged surface reconciles without re-probing the agent', () => withTempDir('discovery', async dir => { + const { ctx } = await bootHost() + try { + await ctx.plugin(Subprocess) + await ctx.plugin(Sandbox, {}) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { + resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: session.header.cwd, sessionId: session.id }), + })) + await ctx.plugin(discovery, { required: true }) + const agent = { id: 'probe-memo', session: { id: 'probe-memo', mode: 'danger-full-access', header: { cwd: dir } } } + await ctx.plugin({ inject: ['tools'], apply(inner) { agent.ctx = createScope(inner, agent).ctx } }) + await ctx.serial('agent/created', { agent, source: 'startup' }) + + const prompt = ctx.get('systemPrompt'), assemble = prompt.assemble.bind(prompt) + let probes = 0 + prompt.assemble = context => { if (isPresentationProbe(context)) ++probes; return assemble(context) } + try { + // A real registration moves the surface, so the first reconcile owes (and makes) a probe. + const registered = ctx.get('tools').register(foreignTool('probe_memo_alpha')) + await wait(300) + const settled = probes + assert.ok(settled > 0, 'a registry change probes the agent at least once (precondition)') + // A tools/change with no registry movement behind it must NOT re-probe: the memo hits. + await ctx.serial('tools/change', {}) + await wait(300) + assert.equal(probes, settled, 'an unchanged surface answers from the memo; a revert to a direct probe re-assembles per change') + registered[Symbol.dispose]?.() ?? registered.dispose?.() + } finally { prompt.assemble = assemble } + } finally { await shutdown(ctx) } +})) + +report('discovery-probe-memo') diff --git a/test/ptc-surface.test.mjs b/test/ptc-surface.test.mjs index e80208b7..0dc21591 100644 --- a/test/ptc-surface.test.mjs +++ b/test/ptc-surface.test.mjs @@ -113,15 +113,6 @@ await test('a host without a reachable view still names the surface', () => with assert.deepEqual(surfaceNames(surfaceOf(noView, key)), projected, 'the fallback names exactly what schemas() projects') })) -/** - * Act on the registry from inside a plugin mounted on one scope. - * - * A scoped registration or restriction refuses the host's unscoped context, so the call - * has to arrive through a context carrying the agent's scope tag. - * @param agent - the scoped context to mount on. - * @param act - receives that context's scoped registry, returns a disposer. - * @returns the disposer. - */ /** * Mount a plugin on the agent's scope and act on the registry from inside it. * diff --git a/test/standard-mode-costs.test.mjs b/test/standard-mode-costs.test.mjs new file mode 100644 index 00000000..fa733bc6 --- /dev/null +++ b/test/standard-mode-costs.test.mjs @@ -0,0 +1,128 @@ +/** + * The name-only read this branch introduces: `visibleNames` must not deep-copy schemas. + * + * `visibleTools` and the host-shell denial gate used `schemas()`, which deep-copies every + * visible definition's parameters — on the published host 34.2 ms for 258 tools at the + * issue's own surface shape — just to keep the names. The view reports the same names, + * in the same order, for ~0.043 ms. These checks pin the shape rather than a wall-clock + * number, so they hold on any host. + * + * The discovery pre-step probe is deliberately NOT memoized and stays out of this file: it + * is the path by which a presentation that flips without the names moving corrects itself + * (see `test/ptc-sdk.test.mjs`, the same-layer handoff case), so its assembly per step is + * load-bearing, not overhead. + */ +import { createScope } from '@deepseek-ai/dsh-scope' +import { bootHost, shutdown } from './lib/host.mjs' +import { assert, test, report, waitFor } from './lib/harness.mjs' +import { applyComponent } from '../lib/index.js' +import { visibleNames } from '../lib/surface.js' + +/** A registered tool whose schema is big enough that a deep copy is measurable. */ +const tool = name => ({ + name, + description: `visible names fixture ${name}`, + parameters: { + type: 'object', + properties: Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`field_${index}`, { + type: 'object', + description: `nested option group ${index} of ${name}, long enough that a deep copy shows up`, + properties: { mode: { type: 'string', enum: ['fast', 'safe', 'strict'] } } + }])) + }, + output: { schema: { type: 'object', properties: { ok: { type: 'boolean' } } }, render: () => [] }, + execute: async () => ({ content: 'ok' }) +}) + +await test('a name-only read does not project schemas', () => withRegistry(async ({ tools, key, calls }) => { + calls.count = 0 + const names = visibleNames(tools, key) + assert.equal(calls.count, 0, 'reading visible names must not deep-copy schemas; the tooling sections pay this per assembly') + assert.deepEqual(names, ['alpha', 'beta'], 'the names come back in view order') +})) + +await test('a name-only read matches the schemas() projection for every scope shape', () => withRegistry(async ({ tools, key }) => { + for (const scope of [key, undefined]) { + const projected = tools.schemas(scope).map(schema => schema.name) + assert.deepEqual(visibleNames(tools, scope), projected, `names must match the projection for ${scope === undefined ? 'the global layer' : 'a scoped key'}`) + } +})) + +await test('a host without a reachable view still names the tools', () => withRegistry(async ({ tools, key }) => { + // The fallback is the correctness branch: a host whose view this module cannot reach + // must still produce the same names, paying the copy rather than returning nothing. + const projected = tools.schemas(key).map(schema => schema.name) + const noView = { schemas: scope => tools.schemas(scope) } + assert.deepEqual(visibleNames(noView, key), projected, 'the fallback names exactly what schemas() projects') +})) + +await test('a tooling section renders from view names, not a projection', () => withRegistry(async ({ ctx, tools, key }) => { + // The shell/file tooling sections run on every assembly. Reading them through the + // projection deep-copies every schema to keep a handful of owned names; a revert to + // `schemas()` makes `projected` non-zero here. + let projected = 0 + const original = tools.schemas + tools.schemas = function (...args) { ++projected; return original.apply(this, args) } + const dispose = ctx.systemPrompt.section({ + name: 'tooling:shell', order: 100, interpolate: false, + text: context => visibleNames(tools, context.scope).filter(name => name === 'alpha').join(', ') || '(none)' + }) + try { + const first = await ctx.systemPrompt.assemble({ scope: key }) + const second = await ctx.systemPrompt.assemble({ scope: key }) + assert.equal(projected, 0, 'a name-only section must not project schemas, at any assembly') + assert.ok(first.sections.some(each => each.name === 'tooling:shell' && each.text.includes('alpha')), 'the section names the owned tool') + assert.ok(second.sections.some(each => each.name === 'tooling:shell' && each.text.includes('alpha')), 'and keeps naming it on the next assembly') + } finally { dispose(); tools.schemas = original } +})) + + +await test('the host-shell denial gate restricts without projecting schemas', () => withRegistry(async ({ ctx }) => { + // `installHostShellDenial` names the host's shell tools to deny them for each agent. + // It shares the name-only read, so it must not deep-copy either: a revert to `schemas()` + // makes `projected` non-zero here while the restriction itself keeps working either way. + ctx.get('tools').register({ + name: 'bash', description: 'foreign host shell fixture', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'object' }, render: () => [] }, execute: async () => ({ content: 'ok' }), + }) + let projected = 0 + const tools = ctx.get('tools') + const original = tools.schemas + tools.schemas = function (...args) { ++projected; return original.apply(this, args) } + let fiber + try { + fiber = await applyComponent(ctx, { shellPolicy: 'deny-host-shell', publishBashTool: false }, 'shell') + const target = { id: 'denied-' + Math.random(), session: { id: 'denied', mode: 'danger-full-access', header: { cwd: process.cwd() } } } + await ctx.plugin({ inject: ['tools'], apply(inner) { target.ctx = createScope(inner, target).ctx } }) + await ctx.serial('agent/created', { agent: target, source: 'startup' }) + // The wait itself must read through the view: `scopedToolNames` projects, and the + // wait's own projection would be indistinguishable from the gate's. + await waitFor(() => !visibleNames(tools, target).includes('bash'), { timeoutMs: 5000, what: 'bash denied' }) + assert.equal(projected, 0, 'denying the host shell must not project schemas; the gate runs per agent') + assert.ok(original.call(tools).map(schema => schema.name).includes('bash'), 'the host layer keeps the tool; only the scope is denied') + } finally { + tools.schemas = original + await fiber?.dispose() + } +})) + +/** + * Boot a host with the fixture tools registered and one scoped key. + * @param {Function} body - the check to run against that host. + */ +async function withRegistry (body) { + const { ctx } = await bootHost() + try { + const tools = ctx.get('tools') + tools.register(tool('alpha')) + tools.register(tool('beta')) + const key = { id: 'names-' + Math.random() } + createScope(ctx, key) + const original = tools.schemas + const calls = { count: 0 } + tools.schemas = function (...args) { ++calls.count; return original.apply(this, args) } + try { await body({ ctx, tools, key, calls }) } finally { tools.schemas = original } + } finally { await shutdown(ctx) } +} + +report('standard-mode-costs') From 08c7c3f83644ccfdb2bdad0673cbecb98f7f35c0 Mon Sep 17 00:00:00 2001 From: Richeir Date: Sat, 24 Oct 2026 14:49:53 +0800 Subject: [PATCH 14/50] feat(bench): make A/B sealing opt-in and wire it to the runner bench/cli.mjs spawns bench/run.mjs as a child and cannot inject a callback, so the runner's requirement of an injected io.seal made every interactive sweep exit 3 before spawning anything unless --no-seal was passed. Sealing is now opt-in with --seal, and every sweep - sealed or not - keeps each arm's results/ and summary.json, which bench report renders the report from. --seal resolves bench/run/seal.mjs (an injected io.seal still wins), fingerprints both arms by hashing \dsh --profile --dump-config\ into the arms[].profileHash that sealRun requires, and refuses before any session when a fingerprint cannot be read. sealSweep adapts the seal context's {base, cand} directory map to the directory list sealRun folds; that mismatch was invisible to the injected doubles and only a real sweep could expose it. --- bench/run.mjs | 134 +++++++++++++++++++++++++++++------ bench/run/schedule.mjs | 2 +- test/bench-schedule.test.mjs | 103 +++++++++++++++++++++++---- 3 files changed, 202 insertions(+), 37 deletions(-) diff --git a/bench/run.mjs b/bench/run.mjs index c6e66b34..07480359 100644 --- a/bench/run.mjs +++ b/bench/run.mjs @@ -8,9 +8,11 @@ * `bench/run/schedule.mjs`. It does not spawn a child, does not aggregate and does not * judge: scheduling is `run/schedule.mjs`, one session is `run/session.mjs` (P7), * aggregation is `lib/aggregate.mjs` (P6), the verdict is `lib/compare-core.mjs` - * (P11a), and sealing is the **injected** `io.seal` callback — `bench/run/seal.mjs` - * (P8a) is not imported here, deliberately, so this slice lands while P8a is still in - * flight; the wiring supplies it. + * (P11a), and sealing is opt-in: `--seal` writes the schema-validated `run.json` through + * `bench/run/seal.mjs` (P8a), while every sweep keeps each arm's `results/` and + * `summary.json` either way. An injected `io.seal` supplies the document an embedder + * wants; the CLI spawns this entry as a child and cannot inject one, so the flag has to + * select the real seal rather than a missing slice. * * Why one process for both arms: the pre-#205 shape spawned one runner per arm, so * every baseline run happened before every candidate run and the machine's drift @@ -26,14 +28,16 @@ import fs from 'node:fs' import path from 'node:path' +import { createHash } from 'node:crypto' import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { EXIT, UsageError, planAb } from './cli.mjs' -import { LLM_OVERLAY, REPO_ROOT, resolveDshEntry } from './lib/env.mjs' +import { LLM_OVERLAY, REPO_ROOT, resolveDshEntry, spawnPlan } from './lib/env.mjs' import { TASKS_DIR, loadTasks, selectTasks } from './lib/tasks.mjs' import { RUNS_DIR } from './lib/next-folder.mjs' import { DEFAULT_REPEAT, armOrder, createInterrupt, planPairs, planSessions, repeatsById, runSweep } from './run/schedule.mjs' +import { sealRun } from './run/seal.mjs' /** The runner-only flags that take a value; `--task`/`--repeat`/`--folder`/`--base`/`--cand` are parsed by the CLI. */ const VALUE_FLAGS = ['--sleep-ms', '--out', '--heartbeat-ms', '--stall-ms', '--timeout', '--dsh', '--llm-patch', '--model'] @@ -73,12 +77,14 @@ export function abUsage() { ' --keep keep each temporary workspace', ' --verbose narrate every event line', ' --show print the pair-by-pair plan and exit without running', - ' --no-seal run without the injected seal callback (no run.json; said out loud)', + ' --seal also seal the sweep into the schema-validated run.json (default:', + ' every arm keeps results/ and summary.json, nothing else)', + ' --no-seal explicit off; this is already the default, kept for old command lines', ' -h, --help this text', '', - 'Exit codes: 0 ok · 1 a failed sweep · 2 usage error · 3 sealing is not wired yet', - '(the caller injects P8a\'s sealRun as `io.seal`; without it, and without --no-seal,', - 'nothing is spawned so no token is spent on an unsealable sweep).', + 'Exit codes: 0 ok · 1 a failed sweep · 2 usage error', + 'Sealing is opt-in: without --seal a sweep writes each arm\'s results/ and summary.json,', + 'and `bench report` renders the report from those summaries.', ].join('\n') } @@ -109,7 +115,7 @@ export function parseRunArgs(argv, context = {}) { aa: false, verbose: false, keep: false, - noSeal: false, + seal: false, noLlmPatch: false, sleepMs: 0, out: null, @@ -125,7 +131,8 @@ export function parseRunArgs(argv, context = {}) { if (flag === '--aa') options.aa = true else if (flag === '--verbose' || flag === '-v') options.verbose = true else if (flag === '--keep') options.keep = true - else if (flag === '--no-seal') options.noSeal = true + else if (flag === '--seal') options.seal = true + else if (flag === '--no-seal') options.seal = false else if (flag === '--no-llm-patch') options.noLlmPatch = true else if (flag === '--label' || flag === '--profile') { throw new UsageError(`${flag} named one arm of a per-process run; \`ab\` runs both arms itself — use --base/--cand`) @@ -192,6 +199,71 @@ export function resolveLlmPatch(options, context = {}) { return { path: overlay, implicit: true } } +/** + * The default profile fingerprint probe: the composed configuration `dsh` reports for one + * profile. `setup` reads the same tree back to prove a pin really landed, so hashing it + * records what each arm ran rather than what its file was called. + * @param {{file: string, kind: 'js'|'exe'}} entry - the resolved dsh launcher. + * @param {string} profile - the profile name to dump. + * @param {{run?: Function, cwd?: string, env?: Record}} [context] - injectable probe, directory and environment. + * @returns {string} the command's stdout and stderr. + * @throws {Error} when it cannot start or exits nonzero: an arm with no fingerprint is not evidence. + */ +export function dumpProfileConfig(entry, profile, context = {}) { + const plan = spawnPlan(entry, ['--profile', profile, '--dump-config']) + const result = (context.run ?? spawnSync)(plan.command, plan.args, { + cwd: context.cwd ?? REPO_ROOT, + env: context.env ?? process.env, + encoding: 'utf8', + timeout: 60_000, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (result.error) throw new Error(`could not run "dsh --profile ${profile} --dump-config": ${String(result.error.message ?? result.error)}`) + if (result.status !== 0) { + const detail = String(result.stderr ?? '').trim().split('\n').filter(Boolean).pop() ?? '' + throw new Error(`"dsh --profile ${profile} --dump-config" exited ${String(result.status)}${detail === '' ? '' : `: ${detail}`}`) + } + return `${result.stdout ?? ''}\n${result.stderr ?? ''}` +} + +/** + * Fingerprint both arms: the SHA-256 of each profile's dumped configuration, which + * `bench/run.schema.json` records in `arms[]` and `sealRun` requires before it will seal. + * @param {{side: string, profile: string}[]} arms - the two arms of the sweep. + * @param {{entry?: object, dump?: (entry: object, profile: string, context: object) => string, env?: object}} [context] - the launcher and the injectable probe. + * @returns {Record} the hex digest of each side's composed configuration. + * @throws {Error} when a dump prints nothing, naming the arm that has no fingerprint. + */ +export function profileFingerprints(arms, context = {}) { + const dump = context.dump ?? dumpProfileConfig + const hashes = {} + for (const arm of arms) { + const text = dump(context.entry, arm.profile, context) + if (typeof text !== 'string' || text.trim() === '') { + throw new Error(`"dsh --profile ${arm.profile} --dump-config" printed nothing, so the ${arm.side} arm has no profile fingerprint`) + } + hashes[arm.side] = createHash('sha256').update(text).digest('hex') + } + return hashes +} + +/** + * The default seal: adapt the sweep's seal context to {@link sealRun}. + * + * The callback is handed the two arm directories as a `{base, cand}` map, while `sealRun` + * folds a list of sweep directories into one document and places `run.json` in their + * common parent (the group folder). Handing the map straight to `sealRun` fails on + * `dirs.map` — which no injected double could catch, because a double never inspects it. + * @param {{dirs: {base: string, cand: string}|string[], arms: object, meta?: object}} context - the seal context. + * @param {(options: object) => object} [seal] - the sealer, injectable so a suite can read what it is handed. + * @returns {object} the seal receipt. + */ +export function sealSweep(context, seal = sealRun) { + const dirs = Array.isArray(context.dirs) ? context.dirs : [context.dirs.base, context.dirs.cand] + return seal({ ...context, dirs }) +} + /** The `-