Merge remote-tracking branch 'origin/main' into fix/batch-upload-and-subcommand-exit

# Conflicts:
#	packages/cli/src/config/config.ts
This commit is contained in:
易良 2026-09-18 21:46:45 +09:00
commit bbf79d1711
1493 changed files with 260013 additions and 41629 deletions

View file

@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// Switch all online hk1/hk2 runners between review and CI, twice a day.
// Offline runners are skipped; their labels stay unchanged.
// Running jobs finish normally; labels only control which job starts next.
import { execFile as execFileCallback } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { promisify } from 'node:util';
const execFile = promisify(execFileCallback);
const MANAGED_LABELS = ['ecs-review', 'ecs-qwen'];
// One literal for both the switch filter and the fleet guard below: the
// guard's whole job is to prove the filter will match something, so two
// copies that can drift would reintroduce the silent no-op it prevents.
const FLEET_NAME = /^ecs-qwen-hk[12]-\d+$/;
export function planLabels(runners, mode) {
if (!['review', 'ci'].includes(mode)) {
throw new Error('mode must be review or ci');
}
const target = mode === 'review' ? 'ecs-review' : 'ecs-qwen';
return runners
.filter((r) => r.status === 'online' && FLEET_NAME.test(r.name))
.flatMap((r) => {
const labels = r.labels.map((l) => l.name);
const remove = labels.filter(
(l) => MANAGED_LABELS.includes(l) && l !== target,
);
const add = labels.includes(target) ? [] : [target];
return add.length || remove.length
? [{ id: r.id, name: r.name, add, remove }]
: [];
});
}
async function gh(args) {
const { stdout } = await execFile('gh', args, {
env: { ...process.env, GH_TOKEN: process.env.RUNNER_ADMIN_TOKEN },
maxBuffer: 16 * 1024 * 1024,
});
return stdout;
}
async function main() {
const [repo, mode] = process.argv.slice(2);
if (!/^[\w.-]+\/[\w.-]+$/.test(repo ?? '')) {
throw new Error(
'usage: review-runner-schedule.mjs <owner/repo> <review|ci>',
);
}
// Validate the mode before spending an authenticated call on it.
planLabels([], mode);
if (!process.env.RUNNER_ADMIN_TOKEN) {
throw new Error(
'RUNNER_ADMIN_TOKEN is empty (needs Administration: write)',
);
}
const pages = JSON.parse(
await gh([
'api',
'--paginate',
'--slurp',
`repos/${repo}/actions/runners?per_page=100`,
]),
);
const runners = pages.flatMap((page) => page.runners);
if (!runners.some((r) => FLEET_NAME.test(r.name))) {
throw new Error('no ecs-qwen-hk1-<n> or ecs-qwen-hk2-<n> runner found');
}
const actions = planLabels(runners, mode);
const results = await Promise.all(
actions.map(async ({ id, name, add, remove }) => {
try {
// Add before removing. A failed POST leaves the host in its previous
// pool only; a failed DELETE leaves it in both. Neither leaves it with
// no pool label at all — the reverse order does, and the host then
// matches no runs-on until the next successful switch (up to 12 hours)
// or a manual dispatch. There is no periodic reconciliation.
if (add.length) {
await gh([
'api',
'--method',
'POST',
`repos/${repo}/actions/runners/${id}/labels`,
...add.flatMap((l) => ['-f', `labels[]=${l}`]),
]);
}
for (const label of remove) {
await gh([
'api',
'--method',
'DELETE',
`repos/${repo}/actions/runners/${id}/labels/${label}`,
]);
}
return {
message: `- ${name}: ${[...remove.map((l) => `−${l}`), ...add.map((l) => `+${l}`)].join(' ')}`,
};
} catch (error) {
return {
failed: true,
message: `- ${name}: failed — ${error.message}`,
};
}
}),
);
const summary = [
`### hk1/hk2 runner pool: ${mode}`,
`- ${actions.length} runners to switch`,
...results.map((r) => r.message),
].join('\n');
console.log(summary);
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
}
const failures = results.filter((r) => r.failed).length;
if (failures) throw new Error(`${failures} runner label change(s) failed`);
}
if (process.argv[1]?.endsWith('review-runner-schedule.mjs')) {
main().catch((error) => {
console.error(`::error::${error.message}`);
process.exitCode = 1;
});
}

View file

@ -0,0 +1,225 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import { planLabels } from './review-runner-schedule.mjs';
const runner = (id, labels, extra = {}) => ({
id,
name: `ecs-qwen-hk2-${id}`,
labels: labels.map((name) => ({ name })),
status: 'online',
...extra,
});
describe('review runner schedule', () => {
it('switches online hk1 and hk2 runners, including busy runners', () => {
const runners = Array.from({ length: 32 }, (_, i) =>
runner(i + 1, ['ecs-qwen', 'ecs-autofix', 'diagnostic'], {
name: `ecs-qwen-hk${(i % 2) + 1}-${i + 1}`,
busy: i % 2 === 0,
status: i < 2 ? 'offline' : 'online',
}),
);
const actions = planLabels(runners, 'review');
const onlineRunners = runners.filter(({ status }) => status === 'online');
assert.equal(actions.length, 30);
for (const [i, action] of actions.entries()) {
assert.deepEqual(action, {
id: onlineRunners[i].id,
name: onlineRunners[i].name,
add: ['ecs-review'],
remove: ['ecs-qwen'],
});
}
assert.deepEqual(
actions.map(({ id }) => id),
onlineRunners.map(({ id }) => id),
);
});
it('returns every online review runner to CI and leaves unrelated runners alone', () => {
assert.deepEqual(
planLabels(
[
runner(1, ['ecs-review', 'diagnostic']),
runner(2, ['ecs-autofix'], { name: 'ecs-qwen-hk1-2' }),
runner(3, ['ecs-review'], { name: 'ecs-qwen-hk2-3-extra' }),
runner(4, ['ecs-review'], { status: 'offline' }),
runner(5, ['ecs-review'], { name: 'ecs-qwen-hk3-5' }),
],
'ci',
),
[
{
id: 1,
name: 'ecs-qwen-hk2-1',
add: ['ecs-qwen'],
remove: ['ecs-review'],
},
{
id: 2,
name: 'ecs-qwen-hk1-2',
add: ['ecs-qwen'],
remove: [],
},
],
);
});
it('is idempotent and rejects invalid modes', () => {
assert.deepEqual(
planLabels([runner(1, ['ecs-review', 'diagnostic'])], 'review'),
[],
);
assert.deepEqual(planLabels([runner(1, ['ecs-qwen'])], 'ci'), []);
assert.throws(() => planLabels([], 'invalid'), /mode must be review or ci/);
});
it('wires two daily UTC switches and manual pool selection', () => {
const schedule = readFileSync(
new URL('../workflows/qwen-review-runner-schedule.yml', import.meta.url),
'utf8',
);
assert.deepEqual(
[...schedule.matchAll(/cron: '([^']+)'/g)].map((m) => m[1]),
['0 9 * * *', '0 21 * * *'],
);
assert.ok(
schedule.includes(
"github.event_name == 'workflow_dispatch' && inputs.pool",
),
);
assert.ok(
schedule.includes(
"github.event.schedule == '0 9 * * *' && 'review' || 'ci'",
),
);
assert.ok(schedule.includes('"$GITHUB_REPOSITORY" "$POOL"'));
assert.ok(schedule.includes('secrets.RUNNER_ADMIN_PAT'));
// The in-repo half of the fence on the admin PAT. Losing it is silent:
// a dispatch from any other branch still runs, now executing that
// branch's copy of the planner with the token in its environment. The
// 4-space anchor binds the clause to a JOB-level `if:` — the same
// substring on a step, or quoted in the header prose, would leave the
// checkout and the job running on a non-main dispatch.
assert.match(schedule, /^ {4}if: .*github\.ref == 'refs\/heads\/main'/m);
});
});
// The planner's own entry path, driven through a fake `gh` that logs every
// label call and serves PROBE_RUNNERS as the runner listing.
const writeFakeGh = (dir) => {
writeFileSync(join(dir, 'package.json'), '{"type":"module"}\n');
writeFileSync(
join(dir, 'gh'),
`#!/usr/bin/env node
import fs from 'node:fs';
const args = process.argv.slice(2);
const method = args[args.indexOf('--method') + 1];
if (!args.includes('--method')) {
console.log(JSON.stringify([{ runners: JSON.parse(process.env.PROBE_RUNNERS) }]));
} else {
fs.appendFileSync(process.env.PROBE_LOG, method + '\\n');
if (method === 'POST' && process.env.PROBE_FAIL === 'true') process.exit(1);
}
`,
{ mode: 0o755 },
);
};
const spawnPlanner = (dir, runners, { fail = false } = {}) =>
spawnSync(
process.execPath,
[
fileURLToPath(new URL('./review-runner-schedule.mjs', import.meta.url)),
'example/repo',
'review',
],
{
encoding: 'utf8',
env: {
...process.env,
PATH: dir + delimiter + process.env.PATH,
RUNNER_ADMIN_TOKEN: 'test-only',
GITHUB_STEP_SUMMARY: '',
PROBE_LOG: join(dir, 'calls'),
PROBE_FAIL: String(fail),
PROBE_RUNNERS: JSON.stringify(runners),
},
},
);
it(
'adds before deleting and preserves the old pool when adding fails',
{ skip: process.platform === 'win32' },
() => {
const dir = mkdtempSync(join(tmpdir(), 'runner-switch-'));
try {
const log = join(dir, 'calls');
writeFakeGh(dir);
for (const [labels, fail, expected] of [
[['ecs-qwen', 'ecs-autofix'], false, ['POST', 'DELETE']],
[['ecs-qwen', 'ecs-autofix'], true, ['POST']],
[['ecs-qwen', 'ecs-review', 'ecs-autofix'], false, ['DELETE']],
]) {
writeFileSync(log, '');
const result = spawnPlanner(dir, [runner(1, labels)], { fail });
assert.equal(result.status, fail ? 1 : 0, result.stderr);
assert.deepEqual(
readFileSync(log, 'utf8').trim().split('\n'),
expected,
);
if (fail)
assert.match(
result.stderr,
/::error::1 runner label change\(s\) failed/,
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it(
'fails loudly when no hk1 or hk2 runner exists instead of switching nothing',
{ skip: process.platform === 'win32' },
() => {
// Without the guard a renamed or empty fleet exits 0 after reporting
// "0 runners to switch": the pool never opens, review-pr queues into a
// closed `ecs-review` pool, and GitHub ends each job at 24 hours. There
// is no periodic reconciliation, so that state holds until someone reads
// a green run.
const dir = mkdtempSync(join(tmpdir(), 'runner-switch-'));
try {
const log = join(dir, 'calls');
writeFakeGh(dir);
for (const runners of [
[],
[runner(1, ['ecs-qwen'], { name: 'ecs-qwen-hk3-1' })],
[runner(1, ['ecs-qwen'], { name: 'ecs-qwen-hk1-1-extra' })],
]) {
writeFileSync(log, '');
const result = spawnPlanner(dir, runners);
assert.equal(result.status, 1, result.stderr);
assert.match(
result.stderr,
/::error::no ecs-qwen-hk1-<n> or ecs-qwen-hk2-<n> runner found/,
);
assert.equal(readFileSync(log, 'utf8'), '');
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);

View file

@ -197,6 +197,12 @@ case "${step}" in
publish_package "packages/channels/${channel}" "${publish_marker}" "${channel}"
echo "::endgroup::"
done
# Last on purpose: this is the only name in the sequence CI has never
# published, so a failure here leaves every long-shipped package already
# out instead of stranding them under `set -eo pipefail`.
publish_package 'packages/web-shell'
if [[ "${IS_DRY_RUN}" != "true" ]] && [[ ! -s "${publish_marker}" ]]; then
echo "::warning::Every channel package was already published; nothing shipped"
fi

View file

@ -19,17 +19,18 @@
9256 build-and-publish-image.yml
49610 cd-cua-driver.yml
2076 cd-mobile-mcp.yml
134426 ci.yml
137297 ci.yml
1482 codeql.yml
9389 comment-attachment-guard.yml
1634 desktop-packaging-check.yml
31677 desktop-release.yml
35684 desktop-release.yml
2038 docs-page-action.yml
10005 dsw-swe-verified-release.yml
23879 e2e.yml
26717 e2e.yml
11394 finalize-release.yml
16647 live-host-release.yml
5950 live-host.yml
1343 mobile-shell.yml
7642 main-ci-failure-issue.yml
1686 npm-cache.yml
2709 pnpm-lock-freshness.yml
@ -40,10 +41,11 @@
5942 qwen-autofix-fork-signal.yml
469165 qwen-autofix.yml
7061 qwen-ci-flaky-rerun.yml
261105 qwen-code-pr-review.yml
265415 qwen-code-pr-review.yml
79041 qwen-fleet-shepherd.yml
22680 qwen-issue-followup-bot.yml
5760 qwen-pr-safety-precheck.yml
2518 qwen-review-runner-schedule.yml
27648 qwen-triage-finalize.yml
350381 qwen-triage.yml
9657 release-sdk-java.yml
@ -59,7 +61,7 @@
6777 serve-ab-publish.yml
17013 serve-ab.yml
2641 stale.yml
10920 sync-desktop-to-oss.yml
11328 sync-desktop-to-oss.yml
10018 sync-live-host-to-oss.yml
10988 sync-release-to-oss.yml
2508 tui-parity.yml

View file

@ -30,7 +30,7 @@ on:
version:
description: 'Version to release (without v prefix)'
required: true
default: '0.20.7'
default: '0.20.9'
notarize:
description:
'Codesign + notarize the macOS artifacts (false to skip during
@ -142,9 +142,11 @@ jobs:
fi
SKILL_NODE_REPL_VERSION=$(sed -nE 's/^qwen mcp add --scope user node-repl npx -y @qwen-code\/node-repl-mcp@([^ ]+)$/\1/p' packages/core/src/skills/bundled/computer-use/SKILL.md)
USER_GUIDE_NODE_REPL_VERSION=$(sed -nE 's/^qwen mcp add --scope user node-repl npx -y @qwen-code\/node-repl-mcp@([^ ]+)$/\1/p' docs/users/features/computer-use.md)
BROWSER_USE_SKILL_NODE_REPL_VERSION=$(sed -nE 's/^qwen mcp add --scope user node-repl npx -y @qwen-code\/node-repl-mcp@([^ ]+)$/\1/p' packages/core/src/skills/bundled/browser-use/SKILL.md)
for PAIR in \
"Computer Use skill Node REPL:$SKILL_NODE_REPL_VERSION" \
"Computer Use user guide Node REPL:$USER_GUIDE_NODE_REPL_VERSION"; do
"Computer Use user guide Node REPL:$USER_GUIDE_NODE_REPL_VERSION" \
"Browser Use skill Node REPL:$BROWSER_USE_SKILL_NODE_REPL_VERSION"; do
LABEL=${PAIR%%:*}
VALUE=${PAIR#*:}
if [[ "$VALUE" != "$NODE_REPL_VERSION" ]]; then

View file

@ -22,6 +22,7 @@ on:
branches:
- 'main'
- 'release/**'
- 'omni-experiment'
merge_group:
# The macOS and Windows lanes' ONLY remaining trigger, and therefore this
# repository's only signal about a host that is not Linux with a GNU
@ -86,8 +87,8 @@ env:
# re-runs every suite after `npm ci`, and a push to main always classifies
# `full` (classify_pr computes a profile only for pull_request), so what
# the fast lane skips is still re-checked post-merge.
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs'
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs'
# The growth ratchet and its vitest mirror compare each workflow against
# the PR's base commit to tell "this PR grew the file" apart from "the
# baseline went stale on main" (#9904). Wired once here so every lane

View file

@ -1,8 +1,10 @@
name: 'Desktop Release'
run-name: 'Desktop release ${{ inputs.version }}'
run-name: 'Desktop release ${{ github.event.release.tag_name || inputs.version }}'
on:
release:
types: ['published']
workflow_dispatch:
inputs:
version:
@ -10,7 +12,7 @@ on:
required: true
type: 'string'
qwen_code_ref:
description: 'Qwen Code branch, tag, or commit to bundle.'
description: 'Qwen Code branch or commit to bundle, reachable from main. A release tag whose own commit is off main is refused here: dispatch checks the ref itself, while only the release event peels to its parent.'
required: true
default: 'main'
type: 'string'
@ -65,7 +67,6 @@ on:
clobber:
required: true
type: 'boolean'
permissions:
contents: 'read'
@ -81,9 +82,20 @@ env:
jobs:
prepare:
name: 'Prepare release metadata'
if: |-
${{
github.event_name != 'release' ||
(
github.repository == 'QwenLM/qwen-code' &&
vars.RELEASE_DESKTOP_SYNC_PUBLISH == 'true' &&
startsWith(github.event.release.tag_name, 'v') &&
github.event.release.prerelease == false
)
}}
runs-on: 'ubuntu-latest'
timeout-minutes: 10
outputs:
already_published: '${{ steps.published.outputs.already_published }}'
qwen_code_sha: '${{ steps.source.outputs.sha }}'
tag: '${{ steps.version.outputs.tag }}'
version: '${{ steps.version.outputs.version }}'
@ -97,7 +109,7 @@ jobs:
shell: 'bash'
env:
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
INPUT_VERSION: '${{ inputs.version }}'
INPUT_VERSION: '${{ github.event.release.tag_name || inputs.version }}'
IS_DRAFT: '${{ inputs.draft }}'
IS_DRY_RUN: '${{ inputs.dry_run }}'
IS_PRERELEASE: '${{ inputs.prerelease }}'
@ -112,7 +124,7 @@ jobs:
echo "::error::Desktop prereleases must use a SemVer prerelease suffix, for example 0.2.1-rc.1: $INPUT_VERSION"
exit 1
fi
if [ "$IS_DRY_RUN" = 'false' ] && [ "$IS_DRAFT" = 'false' ] && [ "$IS_PRERELEASE" = 'false' ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
if [ "$IS_DRY_RUN" != 'true' ] && [ "$IS_DRAFT" != 'true' ] && [ "$IS_PRERELEASE" != 'true' ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Published stable Desktop versions must use X.Y.Z: $INPUT_VERSION"
exit 1
fi
@ -127,24 +139,87 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=desktop-v$version" >> "$GITHUB_OUTPUT"
- name: 'Check whether stable release is already published'
id: 'published'
shell: 'bash'
env:
ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}"
FEED_TAG: '${{ env.DESKTOP_FEED_TAG }}'
GH_TOKEN: '${{ github.token }}'
RELEASE_CLOBBER: '${{ inputs.clobber }}'
RELEASE_DRAFT: '${{ inputs.draft }}'
RELEASE_DRY_RUN: '${{ inputs.dry_run }}'
RELEASE_ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
RELEASE_TAG: '${{ steps.version.outputs.tag }}'
RELEASE_VERSION: '${{ steps.version.outputs.version }}'
run: |
set -euo pipefail
echo 'already_published=false' >> "$GITHUB_OUTPUT"
if [ "$GITHUB_EVENT_NAME" != 'release' ] && {
[ "$RELEASE_DRY_RUN" = 'true' ] ||
[ "$RELEASE_DRAFT" = 'true' ] ||
[ "$RELEASE_PRERELEASE" = 'true' ] ||
[ "$RELEASE_CLOBBER" = 'true' ] ||
[ "$RELEASE_ELECTRON_BRIDGE" = 'true' ];
}; then
exit 0
fi
release="$(gh release view "$RELEASE_TAG" --json isDraft,isPrerelease 2>/dev/null)" || exit 0
if ! jq -e '.isDraft == false and .isPrerelease == false' <<< "$release" >/dev/null 2>&1; then
exit 0
fi
directory="$(mktemp -d)"
trap 'rm -rf "$directory"' EXIT
if ! gh release download "$FEED_TAG" --dir "$directory" --pattern 'desktop-latest.json' >/dev/null 2>&1; then
exit 0
fi
if ! jq -e --arg version "$RELEASE_VERSION" '.version == $version' "$directory/desktop-latest.json" >/dev/null 2>&1; then
exit 0
fi
if ! curl -fsSL --connect-timeout 15 --max-time 300 "$ALIYUN_OSS_PUBLIC_BASE_URL/desktop/latest/desktop-latest.json" -o "$directory/oss-desktop-latest.json" >/dev/null 2>&1; then
exit 0
fi
if ! jq -e --arg version "$RELEASE_VERSION" '.version == $version' "$directory/oss-desktop-latest.json" >/dev/null 2>&1; then
exit 0
fi
echo "::notice::Desktop $RELEASE_VERSION is already published; skipping build, publish and sync-oss. Dispatch this workflow with clobber=true to force a rebuild."
echo 'already_published=true' >> "$GITHUB_OUTPUT"
- name: 'Resolve Qwen Code source'
id: 'source'
shell: 'bash'
env:
INPUT_REF: '${{ inputs.qwen_code_ref }}'
INPUT_REF: '${{ github.event.release.tag_name || inputs.qwen_code_ref }}'
IS_DRY_RUN: '${{ inputs.dry_run }}'
run: |
set -euo pipefail
git fetch origin "$INPUT_REF"
sha="$(git rev-parse FETCH_HEAD)"
if [ "$IS_DRY_RUN" = 'false' ]; then
if [ "$GITHUB_REF_NAME" != 'main' ]; then
echo '::error::Published desktop releases must run from main.'
if [ "$IS_DRY_RUN" != 'true' ]; then
if [ "$GITHUB_REF_NAME" != 'main' ] && [ "$GITHUB_EVENT_NAME" != 'release' ]; then
echo '::error::Published desktop releases must run from main or follow a published release.'
exit 1
fi
# CLI release tags usually point at an off-main version-bump commit
# whose parent is on main. Verify that parent, while still building
# the tag itself; tag-publishing permission remains the trust anchor
# for the version-bump commit.
ancestor="$sha"
if [ "$GITHUB_EVENT_NAME" = 'release' ]; then
ancestor="$(git rev-parse "${sha}^")"
fi
git fetch origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$sha" refs/remotes/origin/main; then
echo '::error::Published desktop releases may only bundle commits reachable from main.'
if ! git merge-base --is-ancestor "$ancestor" refs/remotes/origin/main; then
if [ "$GITHUB_EVENT_NAME" = 'release' ]; then
echo "::error::Release $INPUT_REF: the tag commit's first parent $ancestor is not reachable from main, so this step cannot confirm the release was cut from main. That shape is a non-main source ref, or commits added to the release branch after the version bump; the dispatch arm refuses it too."
else
echo '::error::Published desktop releases may only bundle commits reachable from main.'
fi
exit 1
fi
fi
@ -152,6 +227,7 @@ jobs:
build:
name: 'Build ${{ matrix.name }}'
if: "${{ needs.prepare.outputs.already_published != 'true' }}"
needs: 'prepare'
runs-on: '${{ matrix.os }}'
timeout-minutes: 120
@ -248,7 +324,7 @@ jobs:
run: 'node scripts/version.js "${{ needs.prepare.outputs.version }}"'
- name: 'Require updater signing key for publishing'
if: '${{ inputs.dry_run == false }}'
if: "${{ github.event_name == 'release' || inputs.dry_run == false }}"
shell: 'bash'
env:
TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}'
@ -260,7 +336,7 @@ jobs:
fi
- name: 'Import macOS certificate'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'bash'
env:
APPLE_CERTIFICATE: '${{ secrets.APPLE_CERTIFICATE }}'
@ -296,7 +372,7 @@ jobs:
echo "APPLE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV"
- name: 'Configure macOS notarization'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'bash'
env:
APPLE_API_ISSUER: '${{ secrets.APPLE_API_ISSUER }}'
@ -328,7 +404,7 @@ jobs:
} >> "$GITHUB_ENV"
- name: 'Import Windows certificate'
if: "runner.os == 'Windows' && inputs.dry_run == false"
if: "runner.os == 'Windows' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'pwsh'
env:
WINDOWS_CERTIFICATE: '${{ secrets.WINDOWS_CERTIFICATE }}'
@ -382,7 +458,7 @@ jobs:
run: 'npm run test:release'
- name: 'Sign bundled vendor binaries (macOS)'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
working-directory: 'packages/desktop-shell'
shell: 'bash'
env:
@ -435,7 +511,7 @@ jobs:
echo "Signed and verified ${#mach_o[@]} Mach-O binaries under $runtime_dir."
- name: 'Refresh bundled runtime checksums after signing (macOS)'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
working-directory: 'packages/desktop-shell'
run: 'node scripts/prepare-runtime.js --refresh-checksums'
@ -447,7 +523,7 @@ jobs:
working-directory: 'packages/desktop-shell'
shell: 'bash'
env:
DRY_RUN: '${{ inputs.dry_run }}'
DRY_RUN: "${{ github.event_name == 'release' && 'false' || inputs.dry_run }}"
TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}'
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}'
WINDOWS_CONFIG: '${{ env.WINDOWS_CONFIG }}'
@ -462,7 +538,7 @@ jobs:
npm run tauri -- build "${args[@]}"
- name: 'Verify macOS signature'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'bash'
run: |
set -euo pipefail
@ -475,7 +551,7 @@ jobs:
test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements")" = true
- name: 'Verify Windows signature'
if: "runner.os == 'Windows' && inputs.dry_run == false"
if: "runner.os == 'Windows' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'pwsh'
env:
WINDOWS_CONFIG: '${{ env.WINDOWS_CONFIG }}'
@ -592,12 +668,16 @@ jobs:
publish:
name: 'Publish GitHub release'
if: "${{ inputs.dry_run == false && github.repository == 'QwenLM/qwen-code' }}"
if: "${{ (github.event_name == 'release' || inputs.dry_run == false) && github.repository == 'QwenLM/qwen-code' && needs.prepare.outputs.already_published != 'true' }}"
needs:
- 'prepare'
- 'build'
runs-on: 'ubuntu-latest'
timeout-minutes: 20
# The feed move below overwrites desktop-latest.json with --clobber, so it
# sits behind the same deployment gate as the OSS mirror job.
environment:
name: 'production-release'
permissions:
contents: 'write'
steps:
@ -670,7 +750,7 @@ jobs:
echo "url=$release_url" >> "$GITHUB_OUTPUT"
- name: 'Update stable updater feed'
if: '${{ inputs.draft == false && inputs.prerelease == false }}'
if: "${{ github.event_name == 'release' || (inputs.draft == false && inputs.prerelease == false) }}"
env:
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
GH_TOKEN: '${{ github.token }}'
@ -738,7 +818,7 @@ jobs:
sync-oss:
name: 'Mirror stable Desktop release to Aliyun OSS'
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false && github.repository == 'QwenLM/qwen-code' }}"
if: "${{ github.repository == 'QwenLM/qwen-code' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false)) && needs.prepare.outputs.already_published != 'true' }}"
needs:
- 'prepare'
- 'build'
@ -750,6 +830,7 @@ jobs:
with:
version: '${{ needs.prepare.outputs.version }}'
source: 'artifact'
follows_release: "${{ github.event_name == 'release' }}"
secrets:
ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}'
ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}'

View file

@ -138,7 +138,7 @@ jobs:
needs: 'release_gate'
if: "${{ needs.release_gate.outputs.should_run == 'true' }}"
runs-on: ['self-hosted', 'Linux', 'X64', 'qwen-benchmark-dsw-hk-eas']
timeout-minutes: 15
timeout-minutes: 100
env:
RELEASE_TAG: "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}"
QWEN_REF: "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.qwen_release_tag || inputs.release_tag }}"
@ -199,6 +199,48 @@ jobs:
echo "release_url=$(jq -r .html_url release.json)"
} >> "${GITHUB_OUTPUT}"
- name: 'Wait for the release on npm'
# eas-smoke only validates templates and installs nothing, so it is the
# one backend that does not need the release to be on the registry.
if: "${{ needs.release_gate.outputs.execution_backend != 'eas-smoke' }}"
env:
QWEN_REF: '${{ steps.release.outputs.qwen_ref }}'
# npm accepts a publish and queues it ("Your package is being
# processed and may take a few minutes to become available") while the
# Release event fires as soon as release.yml returns. Measured on
# 0.23.4, 0.23.5-preview.0 and 0.24.0: ~25 minutes to the registry, and
# v0.23.0 took 55m22s. The agent cache prepared below installs this
# exact version on this machine, so the wait belongs here, in front of
# it, with the same signal and budget as update-ecs-runner-qwen.yml.
RESOLVE_TIMEOUT_SECONDS: '5400'
RESOLVE_INTERVAL_SECONDS: '30'
run: |-
set -euo pipefail
# The pinned runtime the dispatch script installs with, so the wait
# resolves through the same npm and registry as that install.
eas_node_bin="${EAS_NODE_BIN:-/mnt/workspace/qwen-benchmark-cache/node/runtime/bin}"
export PATH="${eas_node_bin}:${PATH}"
registry="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}"
specifier="@qwen-code/qwen-code@${QWEN_REF#v}"
# Per-attempt stderr is held back so that dozens of identical 404
# blocks do not bury the log; the last one is replayed on give-up.
err_log="$(mktemp)"
deadline=$((SECONDS + RESOLVE_TIMEOUT_SECONDS))
while :; do
resolved="$(npm view "${specifier}" version --registry "${registry}" 2>"${err_log}" | tail -n 1)" || true
if [[ -n "${resolved}" ]]; then
echo "${specifier} is on the registry as ${resolved}"
break
fi
if ((SECONDS >= deadline)); then
cat "${err_log}" >&2
echo "::error::${specifier} is still not resolvable after ${RESOLVE_TIMEOUT_SECONDS}s."
exit 1
fi
echo "${specifier} is not on the registry yet; retrying in ${RESOLVE_INTERVAL_SECONDS}s."
sleep "${RESOLVE_INTERVAL_SECONDS}"
done
- name: 'Dispatch to persistent DSW pool'
id: 'dispatch'
env:

View file

@ -207,6 +207,12 @@ jobs:
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
# The docker leg's host-side locks live in the runner's home: a
# root-owned leftover under ~/.cache/qwen-code-ci fails every
# `exec 9>` against them with EACCES before a test runs (#11990).
if [ -d "${HOME}/.cache/qwen-code-ci" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "${HOME}/.cache/qwen-code-ci" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "${HOME}/.cache/qwen-code-ci" || echo "::warning::could not restore CI cache ownership; docker sandbox locks may stay unreadable"
fi
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"

View file

@ -359,8 +359,9 @@ jobs:
- name: 'Install dependencies'
run: 'npm install --ignore-scripts'
- name: 'Build dependency chain (core → acp-bridge → sdk → qwen-live)'
- name: 'Build dependency chain (browser-use → core → acp-bridge → sdk → qwen-live)'
run: |-
npm run build --workspace @qwen-code/browser-use
npm run build --workspace @qwen-code/qwen-code-core
npm run build --workspace @qwen-code/acp-bridge
npm run build --workspace @qwen-code/sdk

48
.github/workflows/mobile-shell.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: 'Android Mobile Shell'
on:
pull_request:
paths:
- '.github/workflows/mobile-shell.yml'
- 'packages/mobile-shell/**'
- 'scripts/check-desktop-isolation.js'
push:
branches:
- 'main'
paths:
- '.github/workflows/mobile-shell.yml'
- 'packages/mobile-shell/**'
- 'scripts/check-desktop-isolation.js'
workflow_dispatch:
permissions:
contents: 'read'
jobs:
validate:
name: 'Build, unit tests, and lint'
runs-on: 'ubuntu-latest'
timeout-minutes: 20
defaults:
run:
working-directory: 'packages/mobile-shell'
steps:
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Set up Java 17'
uses: 'actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3' # v4
with:
distribution: 'temurin'
java-version: '17'
- name: 'Verify Gradle wrapper'
shell: 'bash'
run: |
expected="$(tr -d '[:space:]' < gradle/wrapper/gradle-wrapper.jar.sha256)"
actual="$(sha256sum gradle/wrapper/gradle-wrapper.jar | cut -d ' ' -f1)"
test "$actual" = "$expected"
- name: 'Build, run unit tests, and lint'
shell: 'bash'
run: './gradlew --no-daemon assembleDebug testDebugUnitTest lintDebug'

View file

@ -13,6 +13,7 @@ on:
- 'packages/*/package.json'
- '!packages/desktop-shell/package.json'
- '!packages/live-host/package.json'
- '!packages/mobile-shell/package.json'
- 'packages/channels/*/package.json'
- 'integrations/*/package.json'
- 'patches/**'
@ -36,6 +37,7 @@ on:
- 'packages/*/package.json'
- '!packages/desktop-shell/package.json'
- '!packages/live-host/package.json'
- '!packages/mobile-shell/package.json'
- 'packages/channels/*/package.json'
- 'integrations/*/package.json'
- 'patches/**'

View file

@ -200,7 +200,6 @@ task-oriented guides — what a maintainer types and what happens next — see:
- [96. review-scan · Scan for PRs with new feedback — Same admission as the scheduled scan below. In-repo PRs fail CLOSED on a missing…](#af-096)
- [97. review-scan · Scan for PRs with new feedback — Skip-labeled PRs are excluded HERE, not only at the address gate: that gate…](#af-097)
- [98. review-scan · Scan for PRs with new feedback — Dispatch-pending marker: a scan that dispatched this PR within…](#af-098)
- [99. review-scan · Scan for PRs with new feedback — Delay-window fallback: a review run parked BEFORE its job starts (the 10-minute…](#af-099)
- [100. review-scan · Scan for PRs with new feedback — Auto-rerun a check that died on INFRASTRUCTURE, not the code (see…](#af-100)
- [101. review-scan · Scan for PRs with new feedback — startedAt is the only staleness clock: a check blocks only if it started within…](#af-101)
- [102. review-scan · Scan for PRs with new feedback — Ack-on-defer (#8888): a real-time human review routed this scan straight here,…](#af-102)
@ -2770,28 +2769,6 @@ after the metadata fetch, so it consumes inspection budget;
acceptable because the case is rare (a PR dispatched <30m ago).
```
<a id="af-099"></a>
### 99. review-scan · Scan for PRs with new feedback — Delay-window fallback: a review run parked BEFORE its job starts (the 10-minute…
In `review-scan` · `Scan for PRs with new feedback`.
```text
Delay-window fallback: a review run parked BEFORE its job
starts (the 10-minute environment wait) has no review-pr
check-run yet, but a push now would still supersede it
(#10110): a parked or pre-threshold run yields to the push
and its work is discarded exactly as the old synchronize
cancel did. Only pull_request_target runs share the PR-scoped
concurrency group — comment/review-triggered runs use per-run
groups that a push never queues behind or supersedes, so
holding the round for one would defer autofix for nothing
(R2-1). Match against the
scan's REVIEW_RUNS_JSON fetch — one page of the review
workflow's runs, empty on lookup failure — by immutable head
SHA or PR number, never by fork-controlled bare branch name.
```
<a id="af-100"></a>
### 100. review-scan · Scan for PRs with new feedback — Auto-rerun a check that died on INFRASTRUCTURE, not the code (see…
@ -4293,15 +4270,19 @@ reviews (QWEN_CI_REVIEW_EXPECTED_HEAD_SHA) and its guard blocks the
final post when the head moved, so even the uncancellable per-run-group
reviews lose their whole run to a head move.
So the retry probes for a live review first, with the scan gate's probe
pair: the statusCheckRollup filter (any live review-pr check from the
review workflow), then the runs-API fallback for runs still parked in
the 10-minute delay window with no check-run yet. The probe sees
LIFECYCLE runs only: a command-triggered run executes against the base
branch, so its review-pr check attaches to main's commit and never
shows under the PR's rollup (the review ack comment says the same), and
the runs fallback is event-scoped to pull_request_target exactly like
the scan gate's (af-099). An in-flight command review therefore does
So the retry probes for a live review first: the statusCheckRollup
filter (any live review-pr check from the review workflow), then its OWN
runs-API fallback for runs still parked in the 10-minute delay window
with no check-run yet. That fallback is this probe's alone — the scan's
dispatch gate dropped it when the `ecs-review` pool let a queued review
sit out a whole closed window, so the scan now holds only on
IN_PROGRESS; holding here is cheaper, because it defers one stale-base
merge that the next round retries rather than a whole round.
The probe sees LIFECYCLE runs only: a command-triggered run executes
against the base branch, so its review-pr check attaches to main's
commit and never shows under the PR's rollup (the review ack comment
says the same), and the runs fallback is event-scoped to
pull_request_target. An in-flight command review therefore does
NOT hold the refresh, and the merge push can still invalidate its
posting — the second hazard named above, still open. Giving command
runs a PR-head-visible signal (a pending check posted at the ack step,

View file

@ -661,7 +661,7 @@ jobs:
# Secret-bearing and executes agent-driven code, but the agent runs inside
# the docker sandbox image and only ever writes a new branch as the
# Full rationale → qwen-autofix.md#af-008
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-agent"]'') || fromJSON(''["ubuntu-latest"]'') }}'
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-autofix"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 180
# route.issue_number is only set for forced dispatches; label events carry
# the issue in the payload, and scan-and-pick runs (cron, unforced
@ -870,9 +870,8 @@ jobs:
exit 1
;;
esac
# The label routing pins ecs-agent. Existing relabelled hosts retain
# ecs-qwen names while future dedicated hosts may use ecs-agent, so
# accept both names and reject any other mis-labelled registration.
# The ecs-autofix label changes routing, not runner names.
# Keep the existing registration-name allowlist.
if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then
case "${RUNNER_NAME}" in
ecs-qwen-*|ecs-agent-*) ;;
@ -2202,7 +2201,7 @@ jobs:
fi
# Reclaim hard-killed scan dirs on this persistent ecs-qwen pool;
# the agent jobs' sweep now runs on a separate ecs-agent pool.
# the agent jobs' sweep now runs on the ecs-autofix pool.
find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true
# Pre-clean before create, mirroring the heavy jobs: run_id is
# public and sequential, and mkdir -p alone would succeed over a
@ -2517,21 +2516,6 @@ jobs:
fi
fi
# Review-workflow id, resolved ONCE per scan for the review-in-flight
# gate below (#8888): during qwen-code-pr-review.yml's 10-minute
# delay-automatic-review wait the review-pr JOB (and thus its
# check-run in statusCheckRollup) does not exist yet, so the rollup
# alone misses a just-triggered review; the runs API sees the run
# by head SHA before its job starts. Empty on lookup failure — the
# gate then degrades to the rollup check only (fail-open here,
# though the BUSY_PRS enumeration below is fail-closed).
REVIEW_WF_ID="$(gh api "repos/${REPO}/actions/workflows/qwen-code-pr-review.yml" --jq '.id' 2> /dev/null || echo '')"
REVIEW_RUNS_JSON='{"workflow_runs":[]}'
if [[ -n "${REVIEW_WF_ID}" ]] \
&& ! REVIEW_RUNS_JSON="$(gh api "repos/${REPO}/actions/workflows/${REVIEW_WF_ID}/runs?per_page=100" 2> /dev/null)"; then
REVIEW_RUNS_JSON='{"workflow_runs":[]}'
fi
# PRs whose review-address is already RUNNING OR QUEUED in any live
# autofix run must not be re-targeted. Schedule/dispatch runs execute
# Full rationale → qwen-autofix.md#af-026
@ -2684,30 +2668,9 @@ jobs:
# Full rationale → qwen-autofix.md#af-028
REVIEW_PR_LIVE="$(jq -r '
[ .[]
| select((((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED")) and ((.name // "") == "review-pr") and ((.workflowName // "") == "🧐 Qwen Pull Request Review"))) ]
| select((((.status // .state // "") == "IN_PROGRESS") and ((.name // "") == "review-pr") and ((.workflowName // "") == "🧐 Qwen Pull Request Review"))) ]
| length > 0
' <<< "${CHECKS_JSON}")"
REVIEW_RUN_STARTED_AT=""
if [[ "${REVIEW_PR_LIVE}" != "true" && -n "${REVIEW_WF_ID}" && -n "${PR_HEAD_OID}" ]]; then
# Delay-window fallback: a review run parked BEFORE its job
# starts (the 10-minute environment wait) has no review-pr
# check-run yet, but a push now would still supersede it
# (#10110: a parked or pre-threshold run yields to the push —
# its work is discarded exactly as a cancel did).
# Full rationale → qwen-autofix.md#af-099
REVIEW_RUN_STARTED_AT="$(jq -r --arg wf "${REVIEW_WF_ID}" --arg pr "${PR}" --arg head "${PR_HEAD_OID}" '
[ .workflow_runs[]?
| select((.workflow_id | tostring) == $wf)
| select((.event // "") == "pull_request_target")
| select((.status // "") | IN("queued", "waiting", "pending", "requested", "in_progress"))
| select(((.head_sha // "") == $head) or any(.pull_requests[]?; (.number | tostring) == $pr))
| (.run_started_at // .created_at // "") ]
| map(select(. != "")) | sort | last // ""
' <<< "${REVIEW_RUNS_JSON}")"
if [[ -n "${REVIEW_RUN_STARTED_AT}" ]]; then
REVIEW_PR_LIVE="true"
fi
fi
# Auto-rerun a check that died on INFRASTRUCTURE, not the code (see
# INFRA_FAILURE_SIGNATURES).
@ -2785,7 +2748,6 @@ jobs:
| select((((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED")) and ((.name // "") == "review-pr") and ((.workflowName // "") == "🧐 Qwen Pull Request Review")))
| (.startedAt // "")
| select(. != "") ] | first // ""' <<< "${CHECKS_JSON}")"
[[ -z "${REVIEW_STARTED_AT}" ]] && REVIEW_STARTED_AT="${REVIEW_RUN_STARTED_AT}"
# An empty key (a queued check with no startedAt yet) would
# make the marker match EVERY future deferral — skip the ack
# this scan rather than arm a permanently-dead dedup.
@ -3718,7 +3680,7 @@ jobs:
# Full rationale → qwen-autofix.md#af-038
# 345 holds both shapes under the 25m reserve: unarmed 130+60+70+60,
# armed 190+60 with the repair chain skipped (af-156).
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-agent"]'') || fromJSON(''["ubuntu-latest"]'') }}'
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-autofix"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 345
permissions:
contents: 'read'
@ -4001,9 +3963,8 @@ jobs:
exit 1
;;
esac
# The label routing pins ecs-agent. Existing relabelled hosts retain
# ecs-qwen names while future dedicated hosts may use ecs-agent, so
# accept both names and reject any other mis-labelled registration.
# The ecs-autofix label changes routing, not runner names.
# Keep the existing registration-name allowlist.
if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then
case "${RUNNER_NAME}" in
ecs-qwen-*|ecs-agent-*) ;;
@ -6432,14 +6393,17 @@ jobs:
CMP_R="$(gh api "repos/${REPO}/compare/${MAIN_HEAD_R}...${REPORT_HEAD}" --jq '.status' 2> /dev/null || echo '')"
if [[ "${CMP_R}" == 'behind' || "${CMP_R}" == 'diverged' ]]; then
# Review-in-flight hold for the loop's OWN report-time
# head move (#10110): same probe pair as the scan's
# dispatch gate (#8888), fail-open on probe errors.
# The probe sees LIFECYCLE runs only: a command run
# executes against the base branch, so its review-pr
# check never shows under this PR's rollup, and the
# runs fallback is event-scoped exactly like the scan
# gate's (af-099) — an in-flight command review does
# NOT hold the refresh (af-155).
# head move (#10110), fail-open on probe errors. Wider
# than the scan's dispatch gate (#8888), which holds
# only on IN_PROGRESS now that a queued review can sit
# out a whole closed `ecs-review` window; a hold here
# only defers one stale-base merge the next round
# retries. The probe sees LIFECYCLE runs only: a
# command run executes against the base branch, so its
# review-pr check never shows under this PR's rollup,
# and the runs fallback is event-scoped to
# pull_request_target — an in-flight command review
# does NOT hold the refresh (af-155).
# Full rationale → qwen-autofix.md#af-155
ROLLUP_R="$(gh pr view "${PR}" --repo "${REPO}" --json statusCheckRollup --jq '.statusCheckRollup // []' 2> /dev/null || echo '[]')"
REVIEW_LIVE_R="$(jq -r '

View file

@ -196,7 +196,7 @@ jobs:
# A command-triggered run executes against the base branch, so its
# `review-pr` check attaches to main's commit and never shows under
# this PR's checks — the link here is the only way to watch it.
ACK_BODY="$(printf '<!-- qwen-review-ack -->\n\n_Qwen Code review request accepted. Review is running in [workflow run](%s). A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes._' "$RUN_URL")"
ACK_BODY="$(printf '<!-- qwen-review-ack -->\n\n_Qwen Code review request accepted. Review is queued for an available runner; follow the [workflow run](%s) for progress. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes._' "$RUN_URL")"
# One ack per PR, and it must sit right under the command that asked
# for it. Editing the previous ack in place kept the count at one but
# left the notice wherever the FIRST request landed — on a 15-comment
@ -495,7 +495,9 @@ jobs:
# (default: 360); must stay above QWEN_REVIEW_MAX_TIMEOUT_MINUTES so
# retry plus posting never hit the cap.
timeout-minutes: '${{ fromJSON(vars.QWEN_REVIEW_JOB_TIMEOUT_MINUTES) }}'
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-agent"]'') || fromJSON(''["ubuntu-latest"]'') }}'
# The schedule switches hk1/hk2 to `ecs-review` at 17:00 Asia/Shanghai
# and back to CI at 05:00. Reviews queue while the pool is closed.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-review"]'') || fromJSON(''["ubuntu-latest"]'') }}'
permissions:
contents: 'read'
pull-requests: 'write'
@ -639,9 +641,12 @@ jobs:
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# If a foreign-owned directory cannot move to a different parent,
# quarantine the runner-owned workspace and recreate the checkout
# root. Warm contents are lost only on this unrecoverable path.
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
@ -3152,6 +3157,24 @@ jobs:
echo "Could not determine the PR number; skipping fallback comment." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# A review-pr job no runner ever picked up has no runner and no
# steps: GitHub ends a job queued for 24 hours (as failure or
# cancelled), and the `ecs-review` pool is closed by day
# (qwen-review-runner-schedule.yml). The generic body's "did not
# complete successfully / retried automatically" would both be
# false, and silence is not the alternative: ack-review-request
# already promised this requester a result here, and a head nobody
# pushes again never queues another run. So it gets its own body
# below. An unreadable job list keeps the generic comment (fail
# toward telling the author something).
never_started="unknown"
if [ "$REVIEW_PR_RESULT" = "failure" ] || [ "$REVIEW_PR_RESULT" = "cancelled" ]; then
never_started="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID:?}/jobs?per_page=100" \
--jq '[.jobs[] | select(.name == "review-pr")] | if length == 0 then "unknown" elif all(.[]; (.runner_name // "") == "" and ((.steps // []) | length) == 0) then "true" else "false" end' 2>/dev/null)" || never_started="unknown"
if [ "$never_started" = "true" ]; then
echo "review-pr in run ${GITHUB_RUN_ID} was never started by a runner; posting the never-started body." >> "$GITHUB_STEP_SUMMARY"
fi
fi
# A push landing mid-review leaves this comment pointing at a dead
# run while a fresh review of the new head already queues (per-run
# concurrency groups are not cancelled by pushes). The run's head
@ -3277,7 +3300,9 @@ jobs:
# gets one body accurate for both flavors. It keeps the
# `[workflow logs](RUN_URL)` markdown link the cross-job dedup
# anchors on, and the retry instruction the timeout flavor needs.
if [ "$REVIEW_PR_RESULT" = "cancelled" ]; then
if [ "$never_started" = "true" ]; then
body="**Qwen Code review never started.** The job ended before a runner picked it up, so no review ran for this head. This can happen when a queued run is cancelled or expires; this step cannot determine the cause. See [workflow logs](${RUN_URL}). Check the latest \`qwen-review-runner-schedule.yml\` run. If you still want a review, re-request with \`@qwen-code /review\` when the \`ecs-review\` pool is available."
elif [ "$REVIEW_PR_RESULT" = "cancelled" ]; then
body="**Qwen Code review was cancelled before a review could be posted.** Nothing failed and nothing is retried automatically: the run was cancelled — by an operator, an upstream event, or the job exceeding its execution time limit. If you still want a review of this PR, request one with \`@qwen-code /review\`. See [workflow logs](${RUN_URL})."
else
body="**Qwen Code review did not complete successfully.** The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with \`@qwen-code /review\`. See [workflow logs](${RUN_URL})."

View file

@ -0,0 +1,63 @@
name: 'Qwen Review Runner Schedule'
# Switch every online hk1/hk2 runner at 17:00 / 05:00 Asia/Shanghai.
# GitHub cron uses UTC. Manual runs can select either pool explicitly.
# Running jobs finish; only the labels for the next job change.
#
# RUNNER_ADMIN_PAT needs runner-label write access: repo scope for a
# classic PAT, or repository Administration: write for a fine-grained PAT.
# Store it in the environment below, restricted to `main`.
# To pause the schedule, disable this workflow.
on:
schedule:
- cron: '0 9 * * *' # 17:00 Asia/Shanghai: enable reviews
- cron: '0 21 * * *' # 05:00 Asia/Shanghai: return to CI
workflow_dispatch:
inputs:
pool:
description: 'Pool for all online hk1/hk2 runners'
type: 'choice'
required: true
default: 'ci'
options:
- 'review'
- 'ci'
permissions:
contents: 'read'
concurrency:
group: 'qwen-review-runner-schedule'
cancel-in-progress: false
jobs:
converge:
# The ref clause is the in-repo half of the fence on RUNNER_ADMIN_PAT:
# `workflow_dispatch` can be aimed at any branch, and this job checks out
# and executes the script from the dispatched ref. Without it the only
# thing stopping branch-chosen script code from running with an admin
# token is the environment's deployment-branch policy, which lives in
# repository settings and is invisible here. `schedule` events always
# evaluate against the default branch, so this only blocks dispatches.
if: "${{ github.repository == 'QwenLM/qwen-code' && github.ref == 'refs/heads/main' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 10
# deployment: false — the job needs the environment's secret, not a
# deployment record for a label switch.
environment:
name: 'qwen-review-runner-schedule'
deployment: false
steps:
- name: 'Checkout the schedule script'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
persist-credentials: false
sparse-checkout: '.github/scripts/review-runner-schedule.mjs'
sparse-checkout-cone-mode: false
- name: 'Switch runner pool'
env:
RUNNER_ADMIN_TOKEN: '${{ secrets.RUNNER_ADMIN_PAT }}'
POOL: "${{ github.event_name == 'workflow_dispatch' && inputs.pool || (github.event.schedule == '0 9 * * *' && 'review' || 'ci') }}"
run: 'node .github/scripts/review-runner-schedule.mjs "$GITHUB_REPOSITORY" "$POOL"'

View file

@ -101,14 +101,42 @@ jobs:
npm ci
- name: 'Verify published export renderer'
# An exported transcript loads its renderer from unpkg, pinned to this
# exact version and guarded by an sha384 SRI digest computed from the
# bytes this build produced, so those bytes have to match what the
# registry serves before the VSIX ships. Both assets are pinned that
# way, so both are checked.
#
# The wait is not incidental. npm accepts a publish and queues it
# ("Your package is being processed and may take a few minutes to
# become available"), and unpkg trails the registry on top of that.
# Measured on 0.23.4, 0.23.5-preview.0 and 0.24.0: ~25m to the
# registry, ~30m to unpkg. Release-event runs therefore start well
# inside the window where these URLs still 404 — v0.24.0's run failed
# exactly there. --retry-all-errors is what makes a 404 retryable;
# without it --retry only covers connection errors and 408/429/5xx, so
# the retries never fired.
#
# --retry-max-time bounds one curl invocation, so the 40 minutes is
# spent against a deadline both fetches share rather than granted to
# each of them.
run: |-
renderer_version=$(node -p "require('./package.json').version")
published_renderer=$(mktemp)
trap 'rm -f "${published_renderer}"' EXIT
curl --fail --location --silent --show-error --retry 3 \
"https://unpkg.com/@qwen-code/qwen-code@${renderer_version}/export-transcript-document.js" \
--output "${published_renderer}"
cmp packages/web-templates/src/export-html/dist/export-transcript-document.js "${published_renderer}"
deadline=$(($(date +%s) + 2400))
for asset in js css; do
budget=$((deadline - $(date +%s)))
if [[ "${budget}" -lt 1 ]]; then
budget=1
fi
curl --fail --location --silent --show-error \
--retry 80 --retry-delay 30 --retry-all-errors --retry-max-time "${budget}" \
"https://unpkg.com/@qwen-code/qwen-code@${renderer_version}/export-transcript-document.${asset}" \
--output "${published_renderer}"
cmp "packages/web-templates/src/export-html/dist/export-transcript-document.${asset}" \
"${published_renderer}"
done
- name: 'Get the version'
id: 'version'

View file

@ -198,6 +198,17 @@ jobs:
if: "${{ runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
# Fail fast on a saturated pool instance instead of dying on ENOSPC
# mid-job (#10035): a re-run lands on an instance with headroom. The
# script rides the selected ref, so a ref that predates it skips.
- &release_disk_floor_gate
name: 'Disk floor gate (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
if [ -f .github/scripts/check-disk-floor.sh ]; then
bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"
fi
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
@ -269,6 +280,7 @@ jobs:
if: "${{ runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
- *release_disk_floor_gate
- &install_release_dependencies
name: 'Install Dependencies'
env:
@ -306,6 +318,7 @@ jobs:
- *checkout_release_validation
- *setup_release_node_hosted
- *setup_release_node_self_hosted
- *release_disk_floor_gate
- *install_release_dependencies
# This materializes the repo-root dist packed below.
@ -352,6 +365,7 @@ jobs:
- *checkout_release_validation
- *setup_release_node_hosted
- *setup_release_node_self_hosted
- *release_disk_floor_gate
- *install_release_dependencies
- &download_release_build
@ -391,6 +405,7 @@ jobs:
- *checkout_release_validation
- *setup_release_node_hosted
- *setup_release_node_self_hosted
- *release_disk_floor_gate
- *install_release_dependencies
- *download_release_build
- *unpack_release_build
@ -426,6 +441,7 @@ jobs:
- *checkout_release_validation
- *setup_release_node_hosted
- *setup_release_node_self_hosted
- *release_disk_floor_gate
- *install_release_dependencies
- *download_release_build
- *unpack_release_build
@ -490,6 +506,7 @@ jobs:
- *checkout_release_validation
- *setup_release_node_hosted
- *setup_release_node_self_hosted
- *release_disk_floor_gate
- *install_release_dependencies
- name: 'Build Bundle'
@ -530,6 +547,7 @@ jobs:
- *checkout_release_validation
- *setup_release_node_hosted
- *setup_release_node_self_hosted
- *release_disk_floor_gate
- *install_release_dependencies
- name: 'Build Bundle'

View file

@ -9,6 +9,13 @@ on:
source:
required: true
type: 'string'
# Deliberately not required. This workflow is also a reusable callee, so
# github.event_name here is always workflow_call and can never observe the
# release that triggered the chain; the caller states it instead. Omitting
# the input means "not following a release", the safe default.
follows_release:
default: false
type: 'boolean'
secrets:
ALIYUN_OSS_ACCESS_KEY_ID:
required: true
@ -35,7 +42,7 @@ concurrency:
jobs:
sync:
name: 'Mirror Qwen Code Desktop to Aliyun OSS'
if: "${{ github.repository == 'QwenLM/qwen-code' && github.ref == 'refs/heads/main' }}"
if: "${{ github.repository == 'QwenLM/qwen-code' && (github.ref == 'refs/heads/main' || inputs.follows_release) }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 90
environment:

2
.gitignore vendored
View file

@ -129,6 +129,8 @@ coverage/
# Dev symlink: qc-helper bundled skill docs (created by scripts/dev.js)
packages/core/src/skills/bundled/qc-helper/docs
# Generated browser-use runtime (staged by scripts/dev.js)
packages/core/src/skills/bundled/browser-use/runtime/
tmp/
.worktrees/

View file

@ -12,6 +12,7 @@
export const workspacePackageNames = new Set([
'@qwen-code/acp-bridge',
'@qwen-code/audio-capture',
'@qwen-code/browser-use',
'@qwen-code/channel-base',
'@qwen-code/channel-dingtalk',
'@qwen-code/channel-dws',

View file

@ -34,6 +34,7 @@ packages/cua-driver/
scripts/codemod/fixtures/
packages/live-host/
packages/desktop-shell/runtime/
packages/core/src/skills/bundled/browser-use/runtime/
# Vendored from mobile-next/mobile-mcp and carries its own eslint config, so
# reformatting it to house style only widens the diff against upstream on the
# next sync. eslint.config.js already ignores packages/mobile-mcp/** for the

View file

@ -0,0 +1,38 @@
# PR #11242 review follow-up verification
Baseline: `41d11d116d10bc92b4119418e7cc87a3350b3f95`. Updated stacked base: `2bce3892b40c4f480c08ada6a7f3a79302463833`. Tested on macOS arm64, Node 22, Chrome for Testing 151.0.7922.34 with isolated profiles. The global Qwen CLI predates this relay; its version was checked, then the actual PR sources and built artifacts were used for verification.
## Regression scenarios
| Scenario | Baseline observation | Required result |
| --- | --- | --- |
| Package verification | 3 failures / 420 tests; deleted ensureSocketDirectory import breaks typecheck | Current suite and typecheck pass with prepareSocketDirectory and protocol-v2 hello fixtures |
| Smoke completion verifier | Ping precedes start; debugger conflict does not retry | Start before ping, full discovery window, retry only TAB_DEBUGGER_CONFLICT |
| Crash and re-registration | Duplicate entries close handoff or close a tab twice | Replace stale entry after successful registration, retain created ownership, clean once |
| Chrome tab disappears after crash | Finalization repeatedly fails and retains stale entry | Forget confirmed STALE_TAB; retain other failures for retry |
| Managed Chrome interrupts | SIGINT/SIGTERM orphan the detached browser and profile | Normal exit, both signals and a signal during normal cleanup remove the process group and profile |
| Managed browser discovery | Branded Chrome can be selected despite extension loading restrictions | Prefer Chromium / Chrome for Testing; reject explicit Google Chrome 137+ |
| Page-owned overlay element | Page element is adopted, modified and removed | Independent overlay node; page element survives unchanged |
| Oversized UTF-8 response | Valid 17 MiB response kills the actual native-host process | Bounded operation error, same session and subsequent response work; multibyte length is counted in bytes |
| Tab registration in Chrome extension | Page.setDownloadBehavior is rejected before navigation | Register without modifying browser download policy; existing Page download events still reach Playwright |
| Windows socket fixtures | New generic tests bind filesystem paths | Unique Windows named pipes; Windows execution remains pending |
## Package checks
From the repository root, run `npm run build`, `npm run typecheck` and `npm run bundle`. From `packages/browser-use`, run `npm run test:ci`; from `packages/chrome-extension`, run `npm run test:ci` and `npm run typecheck`. Run ESLint on changed source and test files.
Accepted results: Browser Use 21 files / 510 tests, extension 10 files / 99 tests; full root build, typecheck and bundle; targeted ESLint.
## Actual browser check
Set `QWEN_BROWSER_USE_CHROME` to a supported cached Chrome for Testing executable and execute `node packages/browser-use/dist/scripts/managed-chrome-preflight.js` after the build. The script creates its own profile, Native Messaging registration and local HTTP fixture.
Verify extension identity and protocol; new tab and claim; AI snapshot and action using a returned ref; scrolled 100x50 JPEG clip; trusted pointer/keyboard/input events; local form navigation; same-origin download; History permission; deliverable finalization and reclaim; continued usability after 65 seconds idle; final cleanup. Verify both the temporary profile and browser processes disappear on exit.
The first full run failed at the unsupported Page.setDownloadBehavior call. An independent raw-CDP Chrome extension probe then confirmed that omitting this command and enabling Page still produces matching download-start/completed events and the expected 43-byte local fixture file. The rebuilt managed preflight passed in 72.103 seconds with exit 0 and all checks true, including 65-second idle keepalive. Its temporary profile was removed and all 20 observed descendant processes exited.
## Evidence boundaries
No personal browser profile or account was used. Lifecycle/signal and large-response failure probes execute the actual sources with controlled subprocesses or browser API fixtures; these are distinct from the real managed Chrome preflight. Windows and the full model-driven public SauceDemo flow were not executed.
Real Chrome also accepted exact 31-, 1,000- and 10,000-character tab-group titles and retrieved a seven-day-old history entry with explicit from. The omitted-from 24-hour default is now documented. Metadata-specific budgets remain a pending design decision; published Node REPL version synchronization remains explicitly deferred. PR #11241's separate cross-origin and timeout download issues were not changed.

View file

@ -62,11 +62,14 @@ npm run bundle # Bundle dist/ into a single dist/cli.js via esbuild
### Development
```bash
npm run dev # Run CLI directly from TypeScript source (no build needed)
npm run dev # Run CLI from TypeScript source
```
Runs the CLI via `tsx` with `DEV=true`. Changes to `packages/core` or
`packages/cli` are reflected immediately without rebuilding.
`packages/cli` are reflected immediately without rebuilding. Browser Use reuses
its built runtime, like other compiled workspace dependencies. After editing
Browser Use, run `npm run build --workspace=@qwen-code/browser-use` to rebuild
and stage its runtime for dev. Normal `npm install` prepares it automatically.
### Unit Testing

View file

@ -12,6 +12,278 @@ are listed; nightly and preview pre-releases are intentionally omitted.
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
> by hand — run `npm run changelog` to regenerate.
## [0.24.0](https://github.com/QwenLM/qwen-code/releases/tag/v0.24.0) - 2026-09-16
### Highlights
_See the complete change list below._
### Breaking Changes
- fix(core)!: let bash expand project directory variables in command hooks ([#11864](https://github.com/QwenLM/qwen-code/pull/11864)) by @qqqys
### Complete Change List
#### Features
- feat(core): cap web_search calls per session ([#11852](https://github.com/QwenLM/qwen-code/pull/11852)) by @qqqys
- feat: track background result execution across daemon and web shell ([#11636](https://github.com/QwenLM/qwen-code/pull/11636)) by @ytahdn
- feat(cli): full /hooks dialog in OpenTUI ([#11903](https://github.com/QwenLM/qwen-code/pull/11903)) by @qqqys
- feat(core): emit hook progress events ([#11906](https://github.com/QwenLM/qwen-code/pull/11906)) by @qqqys
- feat(extensions): ship dynamic workflows from extensions ([#11805](https://github.com/QwenLM/qwen-code/pull/11805)) by @qqqys
- feat(channels): add shared output modes with DingTalk support ([#11822](https://github.com/QwenLM/qwen-code/pull/11822)) by @qqqys
- feat(web-shell): slide the active pill between tabs ([#11844](https://github.com/QwenLM/qwen-code/pull/11844)) by @callmeYe
- feat(daemon): enumerate installed extension skills locally ([#11281](https://github.com/QwenLM/qwen-code/pull/11281)) by @callmeYe
- feat(web-shell): follow the reading position in the global turn navigation ([#11801](https://github.com/QwenLM/qwen-code/pull/11801)) by @callmeYe
- feat(workflows): share a per-turn token budget from a +Nk directive ([#11917](https://github.com/QwenLM/qwen-code/pull/11917)) by @qqqys
- feat(serve): add budget-based ACP child admission ([#11911](https://github.com/QwenLM/qwen-code/pull/11911)) by @doudouOUC
- feat(cli): add bwrap kernel sandbox backend for Linux ([#11614](https://github.com/QwenLM/qwen-code/pull/11614)) by @doudouOUC
- feat(core): support external reasoning profiles and defaults ([#11856](https://github.com/QwenLM/qwen-code/pull/11856)) by @callmeYe
- feat(settings): turn cross-session messaging on by default ([#11840](https://github.com/QwenLM/qwen-code/pull/11840)) by @qqqys
- feat(core): run saved workflows by name and pin workflow grants to script content ([#11943](https://github.com/QwenLM/qwen-code/pull/11943)) by @qqqys
- feat(workflow): preserve source references across runs and resumes ([#11931](https://github.com/QwenLM/qwen-code/pull/11931)) by @qqqys
- feat(workflow): correlate agent dispatches and nested workflow calls ([#11932](https://github.com/QwenLM/qwen-code/pull/11932)) by @qqqys
- feat(workflows): size guideline, structure preview and large-run warning ([#11939](https://github.com/QwenLM/qwen-code/pull/11939)) by @qqqys
- feat(workflows): let the model invoke extension workflows that declare whenToUse ([#11957](https://github.com/QwenLM/qwen-code/pull/11957)) by @qqqys
- feat(daemon): support summary projection for prompt and replay APIs ([#11920](https://github.com/QwenLM/qwen-code/pull/11920)) by @ytahdn
- feat(web-shell): add footnote previews and per-turn sources ([#11480](https://github.com/QwenLM/qwen-code/pull/11480)) by @callmeYe
- feat(telemetry): emit a Goal lifecycle event and metrics ([#11927](https://github.com/QwenLM/qwen-code/pull/11927)) by @qqqys
- feat(cli): reload hook registry when /hooks opens ([#11904](https://github.com/QwenLM/qwen-code/pull/11904)) by @qqqys
- feat(serve): reclaim idle ACP children when admission is full ([#11940](https://github.com/QwenLM/qwen-code/pull/11940)) by @doudouOUC
- feat(web-shell): connect to a selected remote daemon ([#11548](https://github.com/QwenLM/qwen-code/pull/11548)) by @yiliang114
- feat(web-shell): preview files from expanded tool details ([#11982](https://github.com/QwenLM/qwen-code/pull/11982)) by @ytahdn
- feat(serve): start workflow runs with args, sourceRef and a caller-supplied script ([#11979](https://github.com/QwenLM/qwen-code/pull/11979)) by @qqqys
- feat(web-shell): customize context details and collapse snapshots ([#11996](https://github.com/QwenLM/qwen-code/pull/11996)) by @ytahdn
- feat(goal): bill foreground subagents and verifier token usage ([#11998](https://github.com/QwenLM/qwen-code/pull/11998)) by @qqqys
- feat(dingtalk): carry sender identity in direct messages ([#12017](https://github.com/QwenLM/qwen-code/pull/12017)) by @BenGuanRan
- feat(goal): show turn and active-time budgets ([#11999](https://github.com/QwenLM/qwen-code/pull/11999)) by @qqqys
- feat(omni): integrate multimodal media pipeline into main ([#12019](https://github.com/QwenLM/qwen-code/pull/12019)) by @LaZzyMan
#### Bug Fixes
- fix(cua): preserve Linux observations and REPL diagnostics ([#11829](https://github.com/QwenLM/qwen-code/pull/11829)) by @DragonnZhang
- fix(cua): make Linux input delivery adaptive ([#11876](https://github.com/QwenLM/qwen-code/pull/11876)) by @LaZzyMan
- fix(core): keep escaped whitespace at pipe-list entry edges ([#11860](https://github.com/QwenLM/qwen-code/pull/11860)) by @qqqys
- fix(serve): resolve skill identity from the catalog for toggle guards ([#11831](https://github.com/QwenLM/qwen-code/pull/11831)) by @harjothkhara
- fix(standalone): bundle @lydell/node-pty prebuilds so the web terminal works ([#11881](https://github.com/QwenLM/qwen-code/pull/11881)) by @yiliang114
- fix(cua): bound Linux observations and prepare SDK 0.20.9 ([#11910](https://github.com/QwenLM/qwen-code/pull/11910)) by @LaZzyMan
- fix(cli): scope the ACP permission queue to the session ([#11802](https://github.com/QwenLM/qwen-code/pull/11802)) by @chiga0
- fix(ci): separate autofix runner labels from queued reviews ([#11912](https://github.com/QwenLM/qwen-code/pull/11912)) by @yiliang114
- fix(cli): restore shared Skill status and runner CI checks ([#11933](https://github.com/QwenLM/qwen-code/pull/11933)) by @qqqys
- fix(vscode): retire legacy ACP input bridges ([#11928](https://github.com/QwenLM/qwen-code/pull/11928)) by @yiliang114
- fix(web-shell): show Qwen Live tasks in session sidebar ([#11830](https://github.com/QwenLM/qwen-code/pull/11830)) by @qqqys
- fix(ci): isolate runner schedule fixture module scope ([#11938](https://github.com/QwenLM/qwen-code/pull/11938)) by @doudouOUC
- fix(core): carry the PTY load reason on the call, not in module state ([#11923](https://github.com/QwenLM/qwen-code/pull/11923)) by @yiliang114
- fix(core): keep escaped trailing whitespace when trimming hook matchers ([#11871](https://github.com/QwenLM/qwen-code/pull/11871)) by @yiliang114
- fix(core): expand ${session_id} and stamp User-Agent on the Responses wire ([#11947](https://github.com/QwenLM/qwen-code/pull/11947)) by @yiliang114
- fix(core): keep tool parameters on the MiniMax chat-completions wire ([#11842](https://github.com/QwenLM/qwen-code/pull/11842)) by @yiliang114
- fix(web-shell): stop the approval sheet resizing while reading ([#11930](https://github.com/QwenLM/qwen-code/pull/11930)) by @ytahdn
- fix(release): gate pool-routed validation jobs on a disk floor ([#11972](https://github.com/QwenLM/qwen-code/pull/11972)) by @qwen-code-dev-bot
- fix(web-shell): preserve separators in tool description paths ([#11977](https://github.com/QwenLM/qwen-code/pull/11977)) by @ytahdn
- fix(core): persist Goal turn endings outside model history ([#11924](https://github.com/QwenLM/qwen-code/pull/11924)) by @qqqys
- fix(web-shell): keep the recap below a turn's outputs and dedupe it ([#11986](https://github.com/QwenLM/qwen-code/pull/11986)) by @ytahdn
- fix(ci): heal the docker lock cache ownership in e2e-test-linux (#11990) ([#11992](https://github.com/QwenLM/qwen-code/pull/11992)) by @qwen-code-dev-bot
- fix(cli): register model-invocable commands before headless turns ([#11994](https://github.com/QwenLM/qwen-code/pull/11994)) by @qqqys
- fix(vscode): reach the workspace daemon from a remote window's webview ([#11983](https://github.com/QwenLM/qwen-code/pull/11983)) by @yiliang114
- fix(cli): make the ink useBoxMetrics loop guard independent of machine speed ([#11835](https://github.com/QwenLM/qwen-code/pull/11835)) by @yiliang114
- fix(ci): skip completed desktop releases ([#12013](https://github.com/QwenLM/qwen-code/pull/12013)) by @yiliang114
- fix(web-shell): honor ui.theme and general.language in the standalone Web Shell ([#11961](https://github.com/QwenLM/qwen-code/pull/11961)) by @yiliang114
#### Documentation
- docs(hooks): complete the event catalog and correct stale claims ([#11863](https://github.com/QwenLM/qwen-code/pull/11863)) by @qqqys
- docs(goal): retire the Stop-hook era prose after the implementation was removed ([#11922](https://github.com/QwenLM/qwen-code/pull/11922)) by @qqqys
- docs(serve): clarify daemon event recovery contracts ([#12015](https://github.com/QwenLM/qwen-code/pull/12015)) by @wenshao
- docs(users): point onboarding at Token Plan instead of Coding Plan ([#11980](https://github.com/QwenLM/qwen-code/pull/11980)) by @wenshao
- docs(daemon): complete integration path guide ([#12005](https://github.com/QwenLM/qwen-code/pull/12005)) by @yiliang114
#### Internal Changes
- test: record what the Windows inode gates are hiding, and un-skip one ([#11853](https://github.com/QwenLM/qwen-code/pull/11853)) by @yiliang114
- chore(pnpm): correct the range-key rationale and cover the version union ([#11845](https://github.com/QwenLM/qwen-code/pull/11845)) by @yiliang114
- test(cli): mock realpathSync in the review cleanup suite so the cwd witness holds on Windows ([#11893](https://github.com/QwenLM/qwen-code/pull/11893)) by @yiliang114
- refactor(goal): delete the first-generation Stop-hook Goal implementation ([#11900](https://github.com/QwenLM/qwen-code/pull/11900)) by @qqqys
- refactor(vscode): remove unused token limit mirror ([#11946](https://github.com/QwenLM/qwen-code/pull/11946)) by @yiliang114
- ci(desktop): publish the desktop app when the CLI releases ([#11575](https://github.com/QwenLM/qwen-code/pull/11575)) by @yiliang114
- test(cli): unbreak the bwrap sandbox suites on the Windows lane (#11968) ([#11970](https://github.com/QwenLM/qwen-code/pull/11970)) by @qwen-code-dev-bot
- refactor(serve): separate ACP control plane and channel harness ([#11916](https://github.com/QwenLM/qwen-code/pull/11916)) by @wenshao
- test(serve): harden REST docs contract checks ([#12009](https://github.com/QwenLM/qwen-code/pull/12009)) by @yiliang114
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.23.4...v0.24.0
## [0.23.4](https://github.com/QwenLM/qwen-code/releases/tag/v0.23.4) - 2026-09-14
### Highlights
_See the complete change list below._
### Breaking Changes
- Removes configurable message-prefix filtering from channels, so eligible messages now follow normal sender, group, mention, and pairing policies without a prefix. ([#11571](https://github.com/QwenLM/qwen-code/pull/11571)) by @qqqys
- fix(core)!: read command hook timeout in seconds ([#11615](https://github.com/QwenLM/qwen-code/pull/11615)) by @qqqys
### Complete Change List
#### Features
- Lets approved Web Shell Goal proposals start automatically after their owning turn finishes, removing the need to copy a manual /goal set command. ([#11360](https://github.com/QwenLM/qwen-code/pull/11360)) by @qqqys
- feat(sdk): add a peer endpoint so a program outside Qwen Code can join cross-session messaging ([#11560](https://github.com/QwenLM/qwen-code/pull/11560)) by @qqqys
- Adds optional Goal limits via model.goalMaxTurns and model.goalMaxActiveMinutes, and rebases active Goal time on restore. ([#11457](https://github.com/QwenLM/qwen-code/pull/11457)) by @qqqys
- Web Shell scheduled runs can now route to a configured model and session group when New session each run is selected. ([#11396](https://github.com/QwenLM/qwen-code/pull/11396)) by @qqqys
- Browser notifications in Web Shell now identify the session, show prompt and reply excerpts, and open the target session when clicked. ([#11447](https://github.com/QwenLM/qwen-code/pull/11447)) by @doudouOUC
- Improves Web Shell composer compactness and makes context usage clearer across composer, conversation, and sidebar. ([#11509](https://github.com/QwenLM/qwen-code/pull/11509)) by @wenshao
- Adds visual input, proactive assistance, and local memory to the standalone Qwen Live experience. ([#11369](https://github.com/QwenLM/qwen-code/pull/11369)) by @wangxiongts
- Automatic reviews of small static documentation navigation changes now use a focused reviewer with narrower scope and capped outcomes. ([#11456](https://github.com/QwenLM/qwen-code/pull/11456)) by @wenshao
- feat(subagents): add Codex executor and native agent builtins ([#11474](https://github.com/QwenLM/qwen-code/pull/11474)) by @wenshao
- feat(core): add CodeModeOnly tool execution ([#10607](https://github.com/QwenLM/qwen-code/pull/10607)) by @DragonnZhang
- Moved Workflow authoring reference into the bundled workflow-authoring skill, reducing default tool description overhead. ([#11520](https://github.com/QwenLM/qwen-code/pull/11520)) by @qqqys
- Hid the Web Shell sources panel add-source button by default; it can be re-enabled with the addSource=1 URL parameter. ([#11639](https://github.com/QwenLM/qwen-code/pull/11639)) by @callmeYe
- Channel turns in CLI resume, Web Shell, and session previews now display exactly the prompt the model received. ([#11646](https://github.com/QwenLM/qwen-code/pull/11646)) by @qqqys
- Added Web preview panels with desktop and mobile widths, refresh, external opening, saved HTML artifacts, and per-session URL and width memory. ([#11276](https://github.com/QwenLM/qwen-code/pull/11276)) by @wenshao
- Added inline editing and resending of the latest user message in Web Shell, retaining attached images and files while generating a new reply. ([#11650](https://github.com/QwenLM/qwen-code/pull/11650)) by @ytahdn
- feat(core): add permission_mode, agent_id and prompt_id to every hook input ([#11618](https://github.com/QwenLM/qwen-code/pull/11618)) by @qqqys
- Adds a Continue execution action in Web Shell for recoverable interrupted sessions, allowing users to resume the original conversation without duplicating messages. ([#11545](https://github.com/QwenLM/qwen-code/pull/11545)) by @wenshao
- feat(skills): extension skills are named by their extension ([#10841](https://github.com/QwenLM/qwen-code/pull/10841)) by @nerdalytics
- feat(vscode): add an explicit session source switch ([#11584](https://github.com/QwenLM/qwen-code/pull/11584)) by @yiliang114
- feat(core): auto-retry transient network errors (EOF) where Ctrl+Y is unavailable ([#10347](https://github.com/QwenLM/qwen-code/pull/10347)) by @qwen-code-dev-bot
- feat(workflows): add effort and disallowedTools to agent() options ([#11691](https://github.com/QwenLM/qwen-code/pull/11691)) by @qqqys
- feat(goal): rerun a stalled checkpoint in batches, smaller after each stall ([#11690](https://github.com/QwenLM/qwen-code/pull/11690)) by @qqqys
- feat(web-shell): add model role and context window configuration ([#11342](https://github.com/QwenLM/qwen-code/pull/11342)) by @wenshao
- feat(cua): add app-bound actions and compact native observations ([#11705](https://github.com/QwenLM/qwen-code/pull/11705)) by @LaZzyMan
- feat: agent board — share work across independently started agents ([#9402](https://github.com/QwenLM/qwen-code/pull/9402)) by @yiliang114
- feat(review): record a default deadline in the plan so every run has a wall ([#11686](https://github.com/QwenLM/qwen-code/pull/11686)) by @wenshao
- feat(web-shell): make Session Workflow dependencies navigable and quiet its chrome ([#10938](https://github.com/QwenLM/qwen-code/pull/10938)) by @yiliang114
- feat(web-shell): Improve context overview and add manual compression ([#11700](https://github.com/QwenLM/qwen-code/pull/11700)) by @wenshao
- feat(core): make the web_search budget configurable and bound the extractor fallback ([#11692](https://github.com/QwenLM/qwen-code/pull/11692)) by @qqqys
- feat(web-shell): restore the workspace Git picker in the sidebar hover details ([#11818](https://github.com/QwenLM/qwen-code/pull/11818)) by @ytahdn
- feat(web-shell): Add manual compression to composer context hover ([#11782](https://github.com/QwenLM/qwen-code/pull/11782)) by @wenshao
#### Bug Fixes
- Deflakes a replayed-loop CI test by widening the fabricated timeline-event margin from 75 to 300 seconds without changing workflow or product behavior. ([#11581](https://github.com/QwenLM/qwen-code/pull/11581)) by @wenshao
- Fixes autofix gating so regressions count against the brake and test weakening is gated, preventing a fix round from shipping new problems for free. ([#10188](https://github.com/QwenLM/qwen-code/pull/10188)) by @wenshao
- Preserves the originally submitted prompt for UserPromptSubmit hooks so Auto Recall works for Web Shell submissions and opted-in ACP or daemon clients. ([#11455](https://github.com/QwenLM/qwen-code/pull/11455)) by @doudouOUC
- Restores workspace-configured startup channels when qwen serve starts without an explicit channel argument, while explicit channel selection still takes precedence. ([#11472](https://github.com/QwenLM/qwen-code/pull/11472)) by @qqqys
- Fixes qwen serve startup so persisted workspace-scope MCP configuration is loaded after ACP preheat. ([#11145](https://github.com/QwenLM/qwen-code/pull/11145)) by @kabishou11
- Fixes VS Code diff handling so diffs can be closed reliably using either the original or workspace-resolved path. ([#10494](https://github.com/QwenLM/qwen-code/pull/10494)) by @CanReader
- Frees web terminal PTY resources when the terminal exits instead of waiting for later release. ([#11572](https://github.com/QwenLM/qwen-code/pull/11572)) by @yiliang114
- Adds one bounded retry to E2E build artifact uploads to reduce transient CI failures. ([#11375](https://github.com/QwenLM/qwen-code/pull/11375)) by @qwen-code-dev-bot
- Preserves child error details when agent sessions are explicitly closed or killed, improving diagnostic logging. ([#11190](https://github.com/QwenLM/qwen-code/pull/11190)) by @now-ing
- Web Shell now preserves cancelled responses across reloads and shows how long the request ran before cancellation. ([#11608](https://github.com/QwenLM/qwen-code/pull/11608)) by @ytahdn
- Pins the fleet updater install to the /usr/local prefix so runners execute the newly installed version instead of continuing to use an older install on PATH. ([#11593](https://github.com/QwenLM/qwen-code/pull/11593)) by @yiliang114
- fix(core): recover from rejected Responses encrypted reasoning ([#11596](https://github.com/QwenLM/qwen-code/pull/11596)) by @DragonnZhang
- Improves checkpoint verification by stripping markdown fences and retrying with one corrective model call for certain malformed or inconsistent verifier answers. ([#11578](https://github.com/QwenLM/qwen-code/pull/11578)) by @qqqys
- Shows the Try again action in source previews only after attachment or workspace-file load failures, and clears it after a successful retry. ([#11629](https://github.com/QwenLM/qwen-code/pull/11629)) by @callmeYe
- Routes successful plain-text hook stdout to additionalContext for SessionStart, UserPromptSubmit, and UserPromptExpansion events while other events keep system-message behavior. ([#11612](https://github.com/QwenLM/qwen-code/pull/11612)) by @qqqys
- Fixed OpenTUI renderer display gaps across transcript, composer, footer, dialogs, keyboard, and startup approval to match ink. ([#11630](https://github.com/QwenLM/qwen-code/pull/11630)) by @chiga0
- Reattached image snapshots are now labeled as potentially outdated so earlier session images are not treated as current UI state. ([#11602](https://github.com/QwenLM/qwen-code/pull/11602)) by @yiliang114
- Localized VS Code rewind preflight failures when editing a sent message, replacing raw transport errors with a translated message. ([#11628](https://github.com/QwenLM/qwen-code/pull/11628)) by @yiliang114
- fix(core): preserve Responses retry metadata and assistant phases ([#11652](https://github.com/QwenLM/qwen-code/pull/11652)) by @DragonnZhang
- fix(cli): keep the expanded confirmation dialog inside the viewport ([#11656](https://github.com/QwenLM/qwen-code/pull/11656)) by @qwen-code-dev-bot
- fix(core): retry transient server errors in established streams ([#11634](https://github.com/QwenLM/qwen-code/pull/11634)) by @DragonnZhang
- fix(vscode): preserve IDE workspace env var on Remote-SSH (#11556) ([#11624](https://github.com/QwenLM/qwen-code/pull/11624)) by @yiliang114
- fix(vscode): give the edit back when a permission diff is closed ([#11171](https://github.com/QwenLM/qwen-code/pull/11171)) by @yiliang114
- Persists a one-line diagnostic when a Goal evidence checkpoint fails and displays it before the Goal stops, improving visibility into why checkpoints fail. ([#11576](https://github.com/QwenLM/qwen-code/pull/11576)) by @qqqys
- Suppresses unnecessary error toasts in Web Shell when a submission is rejected due to preflight cancellation or abort, avoiding confusing failure messages. ([#11676](https://github.com/QwenLM/qwen-code/pull/11676)) by @yiliang114
- fix(core): redact request-body prefix in Responses debug logs ([#11668](https://github.com/QwenLM/qwen-code/pull/11668)) by @yiliang114
- Correctly labels blocked Goal cards as Goal blocked in Web Shell instead of the broader Goal aborted label, improving status clarity. ([#11174](https://github.com/QwenLM/qwen-code/pull/11174)) by @yu-xin-c
- Updates a CLI serve test to wait for the fire-and-forget handler to finish before completing, improving test reliability without changing production behavior. ([#11417](https://github.com/QwenLM/qwen-code/pull/11417)) by @qwen-code-dev-bot
- Prevents Responses reasoning replay data from being sent to incompatible providers by stripping or ignoring foreign thoughtSignature payloads during request building. ([#11567](https://github.com/QwenLM/qwen-code/pull/11567)) by @yiliang114
- Fixes local-files bridge trust and bystander issues by reserving a resolving state and preventing parked tabs from wiping stored grants on disconnect. ([#11169](https://github.com/QwenLM/qwen-code/pull/11169)) by @wenshao
- Moves the DashScope conversation cache breakpoint before reattached images so the cached prefix no longer shifts between turns. ([#11640](https://github.com/QwenLM/qwen-code/pull/11640)) by @yiliang114
- fix(core): report real stop_hook_active on Stop hooks ([#11613](https://github.com/QwenLM/qwen-code/pull/11613)) by @qqqys
- fix(core): cite web search sources as bare URLs ([#11677](https://github.com/QwenLM/qwen-code/pull/11677)) by @qqqys
- ACP child spawns now derive V8 old-space targets from actual host memory instead of trusting an unlimited cgroup sentinel value. ([#11653](https://github.com/QwenLM/qwen-code/pull/11653)) by @doudouOUC
- Prevents stale concurrent leader assignments from dispatching the same task to multiple teammates. ([#10237](https://github.com/QwenLM/qwen-code/pull/10237)) by @AaronZ345
- fix(telemetry): gate request_text/response_text on logPrompts ([#11670](https://github.com/QwenLM/qwen-code/pull/11670)) by @yiliang114
- fix(vscode): unify session history and remove source switch ([#11713](https://github.com/QwenLM/qwen-code/pull/11713)) by @yiliang114
- fix(acp): preserve original filenames in resource-link prompts ([#11721](https://github.com/QwenLM/qwen-code/pull/11721)) by @zhangxy-zju
- fix(vscode): shut the ACP CLI down gracefully instead of killing it ([#11642](https://github.com/QwenLM/qwen-code/pull/11642)) by @yiliang114
- fix(core): redact error text in usage-statistics telemetry sink ([#11649](https://github.com/QwenLM/qwen-code/pull/11649)) by @yiliang114
- fix(core): reap surviving hook process trees on Windows ([#11623](https://github.com/QwenLM/qwen-code/pull/11623)) by @yiliang114
- fix(core): compute cron next-fire time with epoch arithmetic across DST fall-back ([#11723](https://github.com/QwenLM/qwen-code/pull/11723)) by @yiliang114
- fix(goal): remove backtracking fence unwrap from judge replies ([#11693](https://github.com/QwenLM/qwen-code/pull/11693)) by @yiliang114
- fix(core): keep the mirrored reasoning field off Fireworks requests ([#11701](https://github.com/QwenLM/qwen-code/pull/11701)) by @bluefateludi
- fix(core): fire active-todo reminders at delegation boundaries and user turns ([#10963](https://github.com/QwenLM/qwen-code/pull/10963)) by @yiliang114
- fix(core): guard internal git calls against configured helper programs ([#11669](https://github.com/QwenLM/qwen-code/pull/11669)) by @yiliang114
- fix(lsp): synchronize target documents before queries ([#11443](https://github.com/QwenLM/qwen-code/pull/11443)) by @shenyankm
- fix(core): run web terminal PTYs on the bundled ConPTY backend ([#11643](https://github.com/QwenLM/qwen-code/pull/11643)) by @yiliang114
- fix(core): retry status-less upstream errors instead of ending the turn ([#11291](https://github.com/QwenLM/qwen-code/pull/11291)) by @wenshao
- fix(dashscope): send request metadata only for qwen-family models ([#11606](https://github.com/QwenLM/qwen-code/pull/11606)) by @shoemoney
- fix(web-shell): prevent terminal query freezes and clean up protocol rejection ([#11748](https://github.com/QwenLM/qwen-code/pull/11748)) by @yiliang114
- fix(cli): break Ink useBoxMetrics commit-phase setState loop (React #185) ([#11565](https://github.com/QwenLM/qwen-code/pull/11565)) by @zaalipro
- fix(dingtalk): handle replied rich-text media ([#11759](https://github.com/QwenLM/qwen-code/pull/11759)) by @qqqys
- fix(dingtalk): preserve callback text ([#11757](https://github.com/QwenLM/qwen-code/pull/11757)) by @qqqys
- fix(cli): prevent dialog clipping in short terminals ([#9040](https://github.com/QwenLM/qwen-code/pull/9040)) by @shenyankm
- fix(web-shell): keep mid-turn messages the daemon rejects at idle ([#11289](https://github.com/QwenLM/qwen-code/pull/11289)) by @wenshao
- fix(core): strip AppImage PYTHONHOME/PYTHONPATH from stdio MCP children ([#11763](https://github.com/QwenLM/qwen-code/pull/11763)) by @yiliang114
- fix(cua): address tool failures and efficiency in OSWorld runs ([#11683](https://github.com/QwenLM/qwen-code/pull/11683)) by @tanzhenxin
- fix(cua): bound observations and reduce model round trips ([#11735](https://github.com/QwenLM/qwen-code/pull/11735)) by @DragonnZhang
- fix(web-shell): pin es2021 floor across both build configs and harden terminal tests ([#11758](https://github.com/QwenLM/qwen-code/pull/11758)) by @yiliang114
- fix(core): let the producer's own budget decide shell output size ([#11727](https://github.com/QwenLM/qwen-code/pull/11727)) by @doudouOUC
- fix(review): document the deadline's pause ceilings and improve capture-local errors ([#11779](https://github.com/QwenLM/qwen-code/pull/11779)) by @wenshao
- fix(ci): raise the build heap cap from 3072 to 4096 MB ([#11781](https://github.com/QwenLM/qwen-code/pull/11781)) by @doudouOUC
- fix(cli): reap running monitors when the process dies on an uncaught exception ([#11742](https://github.com/QwenLM/qwen-code/pull/11742)) by @yiliang114
- fix(web-shell): move source download to the header ([#11641](https://github.com/QwenLM/qwen-code/pull/11641)) by @callmeYe
- fix(skills): re-apply a Skill's side effects when a session is resumed ([#11280](https://github.com/QwenLM/qwen-code/pull/11280)) by @TianYuan1024
- fix(web-shell): make the cockpit visual capture deterministic ([#11786](https://github.com/QwenLM/qwen-code/pull/11786)) by @yiliang114
- fix(dingtalk): remove background agent headings ([#11800](https://github.com/QwenLM/qwen-code/pull/11800)) by @qqqys
- fix(cli): treat write-side PTY EIO as a benign teardown race ([#11788](https://github.com/QwenLM/qwen-code/pull/11788)) by @yiliang114
- fix(goal): keep a full-claim checkpoint replay from spending a stall, and tidy batching follow-ups ([#11796](https://github.com/QwenLM/qwen-code/pull/11796)) by @qqqys
- fix(cli): resolve ACP core settings against the active target dir ([#11647](https://github.com/QwenLM/qwen-code/pull/11647)) by @yiliang114
- fix(sdk): support standalone session creation over HTTP ([#11812](https://github.com/QwenLM/qwen-code/pull/11812)) by @doudouOUC
- fix(core): accept Claude Code tool names in tool hook matchers ([#11826](https://github.com/QwenLM/qwen-code/pull/11826)) by @qqqys
- fix(cli): keep history item ids strictly increasing across mixed base timestamps ([#11743](https://github.com/QwenLM/qwen-code/pull/11743)) by @yiliang114
- fix(ci): pass autofix reasoning effort to Kimi K3 ([#11843](https://github.com/QwenLM/qwen-code/pull/11843)) by @yiliang114
- fix(ci): restore Windows test baseline ([#11787](https://github.com/QwenLM/qwen-code/pull/11787)) by @yiliang114
- fix(cua): recover macOS App observation and input for 0.20.7 ([#11841](https://github.com/QwenLM/qwen-code/pull/11841)) by @LaZzyMan
- fix(cli): only suggest -y for tool calls denied for lack of approval ([#11828](https://github.com/QwenLM/qwen-code/pull/11828)) by @qqqys
- fix(web-shell): stabilize goal proposal approval layout ([#11839](https://github.com/QwenLM/qwen-code/pull/11839)) by @qqqys
- fix(dws): align group and direct chat access controls ([#11836](https://github.com/QwenLM/qwen-code/pull/11836)) by @qqqys
#### Performance
- Improves session recovery performance by removing a redundant deep copy of conversation history. ([#11595](https://github.com/QwenLM/qwen-code/pull/11595)) by @DragonnZhang
- perf(web-shell): load metadata on demand and reuse capability checks ([#11644](https://github.com/QwenLM/qwen-code/pull/11644)) by @ytahdn
#### Documentation
- docs(daemon): publish the REST API reference ([#11592](https://github.com/QwenLM/qwen-code/pull/11592)) by @yiliang114
- docs(vscode): record the cross-source delete consequence ([#11730](https://github.com/QwenLM/qwen-code/pull/11730)) by @yiliang114
- docs(daemon): describe the prompt rate-limit and queue-full responses separately ([#11733](https://github.com/QwenLM/qwen-code/pull/11733)) by @yiliang114
- docs: translate permission card and browser notification designs ([#11775](https://github.com/QwenLM/qwen-code/pull/11775)) by @wenshao
#### Internal Changes
- Removes the obsolete DingTalk background response aggregation setting while keeping immediate Agent responses, transports, attachments, splitting, and interactive cards. ([#11570](https://github.com/QwenLM/qwen-code/pull/11570)) by @qqqys
- Adds review tests that pin the fold invariant for multi-line entries and updates related comments without changing executable product behavior. ([#11533](https://github.com/QwenLM/qwen-code/pull/11533)) by @wenshao
- Refactors the DashScope web search client behind a backend interface with no user-facing behavior change. ([#11490](https://github.com/QwenLM/qwen-code/pull/11490)) by @qqqys
- Strengthens integration tests to verify /compress actually reduces token usage. ([#11259](https://github.com/QwenLM/qwen-code/pull/11259)) by @yu-xin-c
- Removes the unused trusted hooks manager and makes unregisterSkillHooks actually remove session hooks registered by a skill's root directory. ([#11621](https://github.com/QwenLM/qwen-code/pull/11621)) by @qqqys
- test(integration): isolate the QWEN_HOME-unset case from the real $HOME (#10900) ([#10920](https://github.com/QwenLM/qwen-code/pull/10920)) by @qwen-code-dev-bot
- test(core): pin the deferred exit-time PTY release against trailing output ([#11585](https://github.com/QwenLM/qwen-code/pull/11585)) by @yiliang114
- Adds missing hook events to the settings schema and hook execution bus so editors no longer flag them as unknown keys and all hook events are dispatched consistently. ([#11620](https://github.com/QwenLM/qwen-code/pull/11620)) by @qqqys
- Consolidates shared Web Shell sidebar test setup into a single harness to prevent inconsistent behavior across suites. ([#9797](https://github.com/QwenLM/qwen-code/pull/9797)) by @bluefateludi
- chore: enforce Playwright version parity and close the deferred #11101 follow-ups ([#11371](https://github.com/QwenLM/qwen-code/pull/11371)) by @yiliang114
- chore(pnpm): gate pnpm-lock on package-lock and declare hoisted imports ([#11625](https://github.com/QwenLM/qwen-code/pull/11625)) by @yiliang114
- test(lockfile): pin the Playwright parity check's first manifest and both nested directions ([#11716](https://github.com/QwenLM/qwen-code/pull/11716)) by @yiliang114
- refactor(core): apply one matcher rule to every hook event ([#11619](https://github.com/QwenLM/qwen-code/pull/11619)) by @qqqys
- test(web-shell): give the 200-record history-viewport smoke case its own budget ([#11740](https://github.com/QwenLM/qwen-code/pull/11740)) by @yiliang114
- test(core): account for the extra Windows fstat in the tail-growth session mocks ([#11750](https://github.com/QwenLM/qwen-code/pull/11750)) by @Bug-killer-prog
- chore(release): bump node repl to 0.1.4 ([#11771](https://github.com/QwenLM/qwen-code/pull/11771)) by @LaZzyMan
- chore(pnpm): scope build approvals, pin the ACP zod peer, add a lockfile freshness lane ([#11797](https://github.com/QwenLM/qwen-code/pull/11797)) by @yiliang114
- ci(lint): stop new cli imports from the core package root ([#11798](https://github.com/QwenLM/qwen-code/pull/11798)) by @yiliang114
### New Contributors
- @kabishou11 made their first contribution in [#11145](https://github.com/QwenLM/qwen-code/pull/11145)
- @CanReader made their first contribution in [#10494](https://github.com/QwenLM/qwen-code/pull/10494)
- @wangxiongts made their first contribution in [#11369](https://github.com/QwenLM/qwen-code/pull/11369)
- @Bug-killer-prog made their first contribution in [#11750](https://github.com/QwenLM/qwen-code/pull/11750)
- @zaalipro made their first contribution in [#11565](https://github.com/QwenLM/qwen-code/pull/11565)
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.23.3...v0.23.4
## [0.23.3](https://github.com/QwenLM/qwen-code/releases/tag/v0.23.3) - 2026-09-10
### Highlights

View file

@ -0,0 +1,922 @@
# 多模态文件识别与元数据提取架构设计
## 状态
- 状态:Draft
- 范围:Qwen Code 内部的多模态文件识别与元数据提取
- 基线:`origin/main`(包含 PR #7484 的工具结果统一处理链路)
- 后续设计:
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)、
[Omni 多模态 Memory 架构设计](./2026-07-29-omni-multimodal-memory.md)、
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md)
## 1. 背景
Qwen Code 当前已经可以从多个入口接触媒体数据:用户可以附加本地文件,
ACP 可以传入 image、audio 或 resource,内置工具、MCP、Extension 和 Skill
也可能返回 inline data、本地路径、URL 或资源链接。
这些入口目前没有共享一套文件身份与元数据语义:
- 本地文件识别主要依赖文件名扩展名和声明的 MIME;
- MCP 的 image、audio 和 blob 主要信任返回方声明的 MIME,`resource_link`
仍可能退化为文本;
- 工具结果虽然已经通过 PR #7484 汇入统一的 `FunctionResponse`
处理点,但媒体引用尚未形成独立、结构化的候选集合;
- 图片尺寸提取、二进制 magic bytes 判断等能力散落在不同用途的工具函数中,
还不能形成一致的音频、视频和图片元数据结果;
- URL、base64、data URI 和 bytes 没有先统一成本地文件再识别的共同规则。
因此,本设计在现有 Qwen Code 输入和工具执行链路上增加一个统一的
`MediaRecognitionService`。它只负责回答两个问题:
1. 一个显式提供的资源是否是图片、音频或视频;
2. 如果是,它的统一元数据和稳定内容身份是什么。
## 2. 目标与非目标
### 2.1 目标
- 支持本地路径、HTTP(S) URL、base64、data URI、bytes 和 MCP resource
等明确结构化的资源表示;
- 不信任扩展名或声明 MIME,以文件内容作为最终类型判断依据;
- 将 URL 和内存数据在受控条件下落为 Qwen Code 管理的本地文件,之后统一走
本地识别链路;
- 为图片、音频和视频输出统一的公共元数据与各模态技术元数据;
- 按统一、可追溯的公式,基于原始资源的技术 metadata 估算每个媒体资源进入
模型时占用的 token 数;
- 以本地路径和内容哈希登记资源身份,并保留原始来源;
- 在用户输入和工具返回两条现有主链路中各设置一个逻辑识别触发点;
- 延续 PR #7484 的原则:各来源先归一化,再在公共漏斗中处理一次。
- 通过顶层 `omni.ingestion` 配置 URL 本地化、sniff 预算、probe 预算和模型可见
metadata 投影;
- 为每次识别记录 resolved ingestion 配置哈希和 probe 后端信息,支持实验复现。
### 2.2 非目标
本文不设计:
- 文件是否能够上传给某个模型;
- 模型路由、Vision Bridge、媒体 payload 拼装或 provider 限制;
- 压缩、切分、抽帧、转码等预处理 policy;
- 衍生物管理和 memory;
- computation 调度;
- 面向第三方的通用媒体平台或新的独立产品。
识别结果以后可以被这些能力消费,但本设计不为它们预先引入抽象。
本文只定义 metadata 的模型可见字段投影,不负责把投影插入具体 Provider 请求。
## 3. 核心决策
### 3.1 一个服务,两处逻辑触发
识别器只有一个实现,位于 `packages/core`。调用它的逻辑时机只有两处:
1. **用户输入完成结构化归一化之后**:TUI、非交互模式、ACP、Web Shell
等入口先把附件或明确资源引用转为 `MediaCandidate`,再统一识别;
2. **工具输出完成统一归一化之后**:内置工具、MCP、Extension 和 Skill
先汇入同一个 normalized tool result,再对其中的 `mediaCandidates`
统一识别。唯一例外是内部 `fixed_policy` 调用:它的结果由发起调用的 policy
orchestrator 独占消费,在同一个“Tool 输出完成后”逻辑时机复用识别服务,但不再
进入公共 Tool result 漏斗,因此不构成第三个触发点。
“两处触发”指逻辑边界,不要求所有客户端共享同一个物理函数。各客户端可以有
自己的输入适配代码,但不得复制 sniff、probe 或 hash 逻辑。
### 3.2 不在每种工具结果里分别识别
工具适配器只承担结构转换:把明确类型的媒体块、媒体 artifact 或资源链接提取为
`MediaCandidate`。它不判断 magic bytes、不运行 ffprobe,也不递归扫描任意对象。
统一工具结果增加媒体候选旁路:
```ts
interface NormalizedToolResult {
responseParts: Part[];
mediaCandidates: MediaCandidate[];
}
```
公共调度链路在工具输出归一化完成后只处理一次:
```ts
const normalized = normalizeToolResult(toolResult);
if (normalized.mediaCandidates.length > 0) {
await mediaRecognitionService.recognizeAll(normalized.mediaCandidates);
}
```
上述公共链路处理 model/client/普通 Tool 结果;`executionOrigin = fixed_policy` 在进入
该链路前由 owning orchestrator 截获,避免相同 policy artifact 被识别两次。
这不是“逐个理解每种工具结构”。Builtin、MCP、Extension 和 Skill
只需在各自现有的 adapter 边界保留结构化媒体引用,后续识别完全共用。
### 3.3 只处理显式候选,不扫描文本
以下内容可以成为候选:
- 用户显式附加或通过文件引用语法解析出的文件;
- ACP 的 image、audio、resource 和 file resource link;
- 工具返回的 inline data、file data、媒体 `ToolArtifact`;
- MCP 的 image、audio、blob、resource 和 `resource_link`;
- 其他入口已经明确标记为媒体资源的 path、URL、data URI 或 bytes。
以下内容不能自动成为候选:
- 普通对话文本中看起来像路径、URL 或 base64 的片段;
- 工具文本输出中偶然出现的路径或 URL;
- 为文件激活规则或 Skill 而产生的通用 `resultFilePaths` 全量列表;
- 任意工具返回对象的递归字段扫描结果。
媒体生产者应通过已有 artifact、typed part 或显式媒体引用表达资源;不能表达时,
只在对应 adapter 增加结构映射,不把来源特例加入识别器。
### 3.4 内容证据优先
识别结果同时保留三种类型证据:
- `declaredMimeType`:调用方或协议声明;
- `extensionMimeType`:文件名或 URL 后缀推断;
- `detectedMimeType`:magic bytes、容器签名和 probe 得到的结果。
最终 `mediaType` 和 `detectedMimeType` 以内容证据为准。声明 MIME 和扩展名只用于
提示、初筛与冲突告警,不能覆盖内容结果。
### 3.5 顶层 Omni ingestion 配置
识别与 metadata 配置直接放在 settings 顶层 `omni.ingestion`。它仍复用 Qwen
Code 现有 settings 加载和 workspace trust,不增加独立配置文件、环境变量、CLI
flag 或热更新。首版要求重启生效。
本文是 `omni.ingestion` 的权威契约;Policy 编排文档中的完整 Omni 示例只做同步
展示。Scope 沿用现有优先级
`SystemDefaults < User < trusted Workspace < System`,未信任 workspace 不参与
合并。普通对象和 scalar 按现有 deep merge/last-wins 处理,所有字段数组整体替换而
不做 concat 或 union;resolved ingestion config hash 在合并、默认值补齐和校验后
计算。
配置只开放会影响实验结果、资源成本或等待时间的参数。以下正确性和安全边界不是
开关:两个统一触发点、显式候选、内容证据优先、受管本地化、SHA-256 身份、
redirect 重新校验、`.part` 原子落盘和下载后重新识别。
```jsonc
{
// Omni 实验配置的唯一顶层入口;不增加 omni.enabled。
"omni": {
// 配置结构版本,用于记录实验和兼容后续结构调整。
"schemaVersion": 1,
// 文件本地化、识别和 metadata 提取配置。
"ingestion": {
// 将 URL、base64 等资源转为本地受管文件的配置。
"localization": {
// HTTP(S) URL 的预检和下载配置。
"url": {
// 超过该大小时必须征求用户下载许可;默认 100 MiB,只允许调低。
"approvalThresholdBytes": 104857600,
// URL 预检最多读取的响应前缀,用于初步判断 magic bytes。
"preflightBytes": 65536,
// HEAD 或 Range 预检的超时时间。
"preflightTimeoutMs": 30000,
// 用户许可后,完整文件下载允许的最长时间。
"downloadTimeoutMs": 600000,
// URL 下载最多允许的重定向次数;每一跳都会重新安全检查。
"maxRedirects": 5,
},
// base64、data URI 和直接 bytes 输入的本地化配置。
"inline": {
// 允许解码并落盘的最大二进制大小;超限返回 resource_limit。
"maxDecodedBytes": 104857600,
},
},
// 文件内容类型识别配置。
"recognition": {
// sniff 阶段最多读取的本地文件字节数;不能改变内容证据优先原则。
"sniffMaxReadBytes": 65536,
},
// Metadata 的 Harness 内部提取与模型可见字段配置。
"metadata": {
// Harness 内部完整 metadata 的提取配置。
"extraction": {
// 图片 probe 的资源控制配置。
"image": {
// 图片尺寸、方向和动图信息提取的最长执行时间。
"timeoutMs": 10000,
},
// 音频 ffprobe 配置。
"audio": {
// 单个音频 probe 的最长执行时间。
"timeoutMs": 30000,
// ffprobe probesize;null 表示使用 ffprobe 默认值。
"probeSizeBytes": null,
// ffprobe analyzeduration;null 表示使用 ffprobe 默认值。
"analyzeDurationUs": null,
},
// 视频 ffprobe 配置。
"video": {
// 单个视频 probe 的最长执行时间。
"timeoutMs": 30000,
// ffprobe probesize;可实验探测精度与耗时的关系。
"probeSizeBytes": null,
// ffprobe analyzeduration;可调整流信息分析深度。
"analyzeDurationUs": null,
},
},
// 控制模型能够看到哪些 metadata,不影响 Harness 内部 policy 判断。
"modelVisibility": {
// 所有媒体都可以展示的公共字段。
"commonFields": [
"displayName",
"sizeBytes",
"detectedMimeType",
"container",
"probe.status",
"tokenEstimate.status",
"tokenEstimate.estimatedTokenCount",
"tokenEstimate.method",
],
// 图片可以展示的技术字段。
"imageFields": [
"width",
"height",
"orientation",
"animated",
"frameCount",
"durationMs",
],
// 音频文件级可以展示的字段。
"audioFields": ["durationMs", "bitRate"],
// 音频 stream 可以展示的字段。
"audioStreamFields": [
"codec",
"sampleRateHz",
"channels",
"channelLayout",
"bitRate",
],
// 视频文件级可以展示的字段。
"videoFields": ["durationMs", "bitRate"],
// 视频 stream 可以展示的字段。
"videoStreamFields": [
"codec",
"width",
"height",
"frameRate",
"bitRate",
],
// 是否向模型展示 MIME 冲突、probe 不完整等非致命警告。
"includeWarnings": true,
// 是否向模型展示内容哈希;内部身份计算不受该开关影响。
"includeContentHash": false,
},
},
},
},
}
```
`metadata.extraction` 是 Harness 的完整事实,供 fixed policy 条件判断和后续 memory
使用;`metadata.modelVisibility` 只控制模型可见字段。字段数组可以为空以进行
metadata 消融实验,但不能导致内部字段停止提取。模型信封中的 `resourceId` 和
`mediaType` 始终存在;`localPath` 和鉴权 header 永不暴露。
## 4. 总体架构
```mermaid
flowchart LR
subgraph User["用户输入链路"]
U1["TUI / Non-interactive / ACP / Web Shell"] --> U2["输入 adapter"]
U2 --> U3["MediaCandidate[]"]
end
subgraph Tool["工具返回链路"]
T1["Builtin / MCP / Extension / Skill"] --> T2["来源 adapter"]
T2 --> T3["NormalizedToolResult"]
T3 --> T4["mediaCandidates"]
end
U3 --> R["MediaRecognitionService"]
T4 --> R
R --> S["解析为受管本地文件"]
S --> N["sniff: magic bytes / container"]
N --> P["probe: image / audio / video"]
P --> T["estimate tokens"]
T --> H["identity: local path + SHA-256"]
H --> M["MediaFileMetadata"]
```
服务内部是一条流水线,不拆成多个对外模块。URL 预检、sniff、probe 和 hash
可以用私有 helper 实现,但对调用方只暴露候选输入和识别结果。
## 5. 数据契约
### 5.1 媒体候选
```ts
type MediaLocator =
| { kind: 'path'; path: string }
| { kind: 'url'; url: string; headers?: Record<string, string> }
| { kind: 'base64'; data: string }
| { kind: 'data_uri'; dataUri: string }
| { kind: 'bytes'; data: Uint8Array }
| { kind: 'mcp_resource'; serverName: string; uri: string };
interface MediaCandidate {
locator: MediaLocator;
origin:
| {
kind: 'user';
surface: 'tui' | 'non_interactive' | 'acp' | 'web_shell';
}
| { kind: 'tool'; toolName: string; callId: string }
| {
kind: 'policy';
executionOrigin: 'fixed_policy';
toolName: string;
invocationId: string;
policyId: string;
stage: 'preprocessing' | 'transport_guard';
}
| {
kind: 'policy';
executionOrigin: 'model' | 'client';
toolName: string;
invocationId: string;
};
displayName?: string;
declaredMimeType?: string;
declaredSizeBytes?: number;
}
```
`MediaCandidate` 表示“值得交给识别器验证的显式资源”,不表示已经确认是媒体。
MCP adapter 必须在 `resource_link` 退化为文本之前保留它;HTTP(S) resource
可归一为 URL,必须通过 MCP server 读取的资源使用 `mcp_resource` locator。
### 5.2 识别结果
```ts
type MediaRecognitionResult =
| { status: 'recognized'; resource: RecognizedMediaResource }
| { status: 'not_media'; evidence: RecognitionEvidence }
| {
status: 'approval_required';
request: LargeDownloadApproval;
}
| {
status: 'failed';
code:
| 'invalid_source'
| 'access_denied'
| 'download_failed'
| 'sniff_failed'
| 'resource_limit';
message: string;
};
interface RecognizedMediaResource {
resourceId: string;
metadata: MediaFileMetadata;
}
interface RecognitionEvidence {
declaredMimeType?: string;
extensionMimeType?: string;
detectedSignature?: string;
reason: 'unsupported_signature';
}
interface LargeDownloadApproval {
sourceUrl: string;
displayName?: string;
thresholdBytes: number;
knownSizeBytes?: number;
downloadedBytes: number;
canResume: boolean;
}
```
`approval_required` 是正常控制流,不是错误。调用入口负责使用自身已有的确认机制
询问用户;同意后以 `largeDownloadApproved` 选项重试同一候选,服务复用可续传的
`.part`。没有交互能力的入口直接把该状态返回给上层,不得静默下载。
### 5.3 公共元数据
```ts
interface MediaFileMetadata {
schemaVersion: 1;
ingestionConfigHash: string;
mediaType: 'image' | 'audio' | 'video';
displayName?: string;
sizeBytes: number;
declaredMimeType?: string;
extensionMimeType?: string;
detectedMimeType: string;
container?: string;
identity: {
algorithm: 'sha256';
contentHash: string;
localPath: string;
};
source: {
originalKind: MediaLocator['kind'];
originalRef?: string;
finalUrl?: string;
etag?: string;
lastModified?: string;
};
probe: {
status: 'complete' | 'partial' | 'unavailable' | 'failed';
backend?: string;
backendVersion?: string;
timeoutMs: number;
probeSizeBytes?: number;
analyzeDurationUs?: number;
message?: string;
};
tokenEstimate: MediaTokenEstimate;
metrics: MediaMetrics;
technical: ImageMetadata | AudioMetadata | VideoMetadata;
warnings: MediaRecognitionWarning[];
}
interface MediaRecognitionWarning {
code:
| 'declared_mime_mismatch'
| 'extension_mime_mismatch'
| 'probe_incomplete';
message: string;
}
interface MediaTokenEstimate {
status: 'estimated' | 'unavailable';
estimatedTokenCount?: number;
method?: 'dashscope_audio_7tps_v1' | 'dashscope_visual_32x32x2_v1';
inputs?:
| {
mediaType: 'audio';
durationMs: number;
tokensPerSecond: 7;
}
| {
mediaType: 'image' | 'video';
width: number;
height: number;
frameCount: number;
patchSize: 32;
mergeFactor: 2;
};
unavailableReason?: string;
}
interface MediaMetrics {
durationMs?: number;
width?: number;
height?: number;
maxWidth?: number;
maxHeight?: number;
frameRate?: number;
frameCount?: number;
bitRate?: number;
sampleRateHz?: number;
channels?: number;
}
```
公共字段保证上层不必先理解来源格式。`resourceId` 是后续 policy 和模型引用资源
的 opaque 标识;真实本地路径只在 Harness 内解析。`originalRef` 不保存 base64、
data URI 正文或鉴权 header,只保存可安全展示的路径、URL 或 resource URI。
`ingestionConfigHash`、probe backend、版本和实际参数用于区分不同实验条件。图片
probe 不使用 `probeSizeBytes` 或 `analyzeDurationUs` 时省略对应字段。
### 5.4 各模态技术元数据
初版字段集合如下:
```ts
interface ImageMetadata {
kind: 'image';
width?: number;
height?: number;
orientation?: number;
colorSpace?: string;
hasAlpha?: boolean;
animated: boolean;
frameCount?: number;
loopCount?: number;
durationMs?: number;
}
interface AudioStreamMetadata {
index: number;
isDefault?: boolean;
codec?: string;
sampleRateHz?: number;
channels?: number;
channelLayout?: string;
bitRate?: number;
durationMs?: number;
}
interface AudioMetadata {
kind: 'audio';
durationMs?: number;
bitRate?: number;
streams: AudioStreamMetadata[];
}
interface VideoStreamMetadata {
index: number;
isDefault?: boolean;
codec?: string;
width?: number;
height?: number;
frameRate?: number;
bitRate?: number;
durationMs?: number;
}
interface VideoMetadata {
kind: 'video';
durationMs?: number;
bitRate?: number;
videoStreams: VideoStreamMetadata[];
audioStreams: AudioStreamMetadata[];
}
```
GIF、APNG、animated WebP 等仍归类为 `image`,通过 `animated`、
`frameCount` 和可获取的 `durationMs` 表达动态属性,不提升为 video。
`metrics` 是 fixed policy 条件引擎使用的稳定扁平字段,不要求条件解析器理解 stream
数组。图片直接使用图片 metadata;音频和视频的 primary stream 优先选择
`isDefault = true`,否则选择 index 最小的 stream。视频 `width`、`height` 和
`frameRate` 来自 primary video stream,`maxWidth` 和 `maxHeight` 是所有视频流的
最大值;音频 `sampleRateHz` 和 `channels` 来自 primary audio stream。文件级
duration/bitrate 优先使用容器值,缺失时再使用 primary stream 值。所有选择规则
固定并写入测试,不能随 ffprobe 输出顺序变化。
## 6. 识别流程
### 6.1 来源解析与本地化
不同 locator 先解析成受管本地文件:
- `path`:经过现有路径访问控制,确认是可读取的普通文件;
- `url`:先执行有上限的远端预检,确认可能是多模态资源后再下载;
- `base64`、`data_uri`、`bytes`:显式解码并写入受管临时文件;decoded bytes
超过 `omni.ingestion.localization.inline.maxDecodedBytes` 时返回
`failed/resource_limit`;
- `mcp_resource`:通过产生该引用的 MCP server 读取,得到 bytes 或 URL 后
进入对应分支。
临时文件先写 `.part`,成功后原子重命名。解析结束后,后续步骤只接收本地路径,
不再区分最初来源。
### 6.2 sniff
sniff 最多读取 `omni.ingestion.recognition.sniffMaxReadBytes`,并在预算内读取容器
索引所需的少量区域,用 magic bytes 和容器签名判断:
- 是否属于 image、audio 或 video;
- 精确 MIME 和容器;
- 声明 MIME、扩展名与内容是否冲突。
sniff 不以文件扩展名作为最终结论。如果内容不属于支持的三种媒体,返回
`not_media`;如果文件无法访问或内容损坏到无法判断,返回 `failed`。
### 6.3 probe
sniff 确认媒体类型后运行对应 probe:
- 图片使用现有 `sharp` 能力提取尺寸、方向、色彩、alpha 和动图信息;
- 音频和视频通过一个内部 probe 接口调用 `ffprobe`,再把容器与 stream 输出
归一为本设计的数据结构;
- 图片、音频和视频分别使用 `omni.ingestion.metadata.extraction` 中的 timeout;
- 音频和视频可以显式设置 `probeSizeBytes` 和 `analyzeDurationUs`,`null` 表示不向
ffprobe 传入对应参数;
- **`ffmpeg`/`ffprobe` 是本实验分支的硬性运行依赖**:启动时检测二进制可用性并
记录版本,缺失时直接启动失败并给出安装指引,不提供无 ffprobe 的降级运行模式。
`probe.status = unavailable` 仅保留用于表达单个文件的探测器异常,不再表示
"环境缺少 ffprobe";
- probe 部分字段失败时返回 `partial`,不能抹掉已经由 sniff 确认的类型。
### 6.4 Token 估算
识别服务在技术 metadata 完成后生成 `tokenEstimate`。估算完全以**发送给模型的
原始媒体资源本身**为依据,不考虑 Provider 服务端的 resize、抽帧或其他预处理;
这也是 URL 资源统一下载到本地的原因之一——本地文件就是估算与投递的唯一事实。
初版采用需求方指定的 DashScope 估算方式,并统一向上取整:
```text
audioTokens = ceil(durationMs / 1000 * 7)
visualTokens = ceil(width * height * frameCount / (32 * 32 * 2))
```
其中:
- 音频固定按每秒 7 tokens 估算;
- 静态图片使用 visual 公式且 `frameCount = 1`;
- animated image 使用 metadata 中的实际 `frameCount`;
- 视频的 `width`、`height` 来自 primary video stream;`frameCount` 优先使用
容器/stream 声明的总帧数,缺失时按 `ceil(durationMs / 1000 * frameRate)` 推导;
- 每个 policy 衍生物重新进入识别链路后,按衍生物自己的技术 metadata 重新估算,
不继承原资源的数值。
原始长视频的估算值会显著大于 Provider 实际计费 token(服务端会抽帧),这是
有意的保守上界:估算的用途是驱动 fixed policy 条件("资源太大 → 先处理"),
而不是复现 Provider 账单。估算方法带版本号(`method` 字段),后续如需引入
服务端口径的第二套估算,可作为新 method 并存,不覆盖本方法。
如果 `durationMs`、`width`、`height` 或 `frameRate`/`frameCount` 等必需字段缺失,
返回 `status = unavailable` 并记录原因;不允许用猜测值填充。
`resource.estimatedTokenCount` 条件字段直接读取
`tokenEstimate.estimatedTokenCount`,它是唯一的估算值存放位置,不在 `metrics`
中重复投影。它是有公式版本和输入依据的估算值,不等于 Provider 最终返回的实际
token usage。
### 6.5 identity 登记
服务在读取或下载文件时流式计算 SHA-256;无法与 sniff/probe 共用读取过程时,
再单独顺序读取一次。最终身份锚点是:
```text
localPath + sha256(content)
```
路径、URL、mtime、size、ETag 和 Last-Modified 都可能变化,只能作为快速校验或
来源信息,不能替代内容哈希。相同内容可以来自多个位置;内容哈希相同表示相同
文件版本,来源记录仍分别保留。
识别成功后为当前 Harness 资源登记 opaque `resourceId`。该 ID 供后续 policy 和
模型 Tool 引用,不替代 `localPath + contentHash` 的内部身份锚点。
## 7. URL 规则与可配置许可边界
URL 是唯一需要在"尚未形成完整本地文件"时先判断是否值得下载的来源。
即使投递层统一采用 DashScope 官方临时上传(见 Policy 编排设计 §10.3),外部
URL 也**不透传给模型**,仍然必须先下载本地化。原因是本地字节是全链路的唯一
事实源:
1. 技术 metadata 依赖完整本地文件(MP4 moov atom 常在文件尾,远程 Range 探测
不可靠);
2. token 估算完全基于原始资源的技术 metadata;
3. policy 加工(裁剪、抽帧、音轨提取)必须操作本地字节;
4. 完整 SHA-256 身份与 Memory 版本判断需要全部字节;
5. oss URL 48h 过期后的重传字节只能来自本地对象库;
6. 外部 URL 可能缺失 `Content-Length`/`Content-Type`、带鉴权或内容漂移,服务端
拉取不可靠且实验不可复现;受管上传 URL 则始终可用。
### 7.1 有界预检
服务先执行 HEAD;如果服务端不支持,再执行带 Range 的有界 GET。预检最多读取
`omni.ingestion.localization.url.preflightBytes`,并服从 `preflightTimeoutMs`,
不把完整响应留在内存中,并尽量使用 `Accept-Encoding: identity`。记录:
- `Content-Length`;
- `Content-Type`;
- `Content-Disposition`;
- `ETag`、`Last-Modified`;
- redirect 后的 final URL;
- 前缀 magic bytes。
预检阶段仍以 magic bytes 为主要证据。HTTP `Content-Type` 只作为声明信息。
如果前缀能确认是非媒体,返回 `not_media`;如果证据不足,返回
`failed/sniff_failed`。两种情况都不为了“试一试”而下载完整文件。
### 7.2 大小许可
许可阈值来自:
```text
omni.ingestion.localization.url.approvalThresholdBytes
```
默认值是 `100 MiB = 100 * 1024 * 1024 bytes`。为保持此前“超过 100 MiB 必须
询问”的边界,实验配置可以调低但不能调高。
- `Content-Length > approvalThresholdBytes`:在完整下载前返回
`approval_required`;
- `Content-Length <= approvalThresholdBytes` 或缺失,但继续写入将使文件超过该
阈值:在写入超限字节前暂停并返回 `approval_required`;
- 用户拒绝:删除 `.part`,不产生本地资源;
- 用户同意:从中断位置继续;服务端不支持 Range 时重新下载;
- 整个过程按实际写入字节计数,不能只相信 HTTP header;
- redirect 最多进行 `maxRedirects` 次;每一跳重新执行访问控制和大小判断;
- 预检和完整下载分别使用配置中的超时,超时按下载失败返回。
用户授权的是当前资源的一次下载,不是对域名或后续所有大文件的永久授权。
### 7.3 下载后的权威识别
远端预检只用于决定是否本地化。下载完成后必须从本地文件重新执行完整的
sniff、probe 和 hash,最终 metadata 不直接采用预检结论。
## 8. 两条现有链路的接入
### 8.1 用户输入链路
各输入面保留自己的语法与协议解析,在完成结构化解析后输出候选:
| 当前入口 | adapter 责任 | 统一触发位置 |
| ------------------ | --------------------------------------------------- | ----------------------- |
| TUI 附件与 `@file` | 将已解析出的真实文件引用变为 path candidate | `@` 命令解析完成后 |
| 非交互输入 | 将 `readManyFiles` 已解析出的显式文件变为 candidate | 文件输入归一化完成后 |
| ACP | 保留 image、audio、resource 和 resource link 的结构 | `#resolvePrompt` 完成后 |
| Web Shell | 将上传控件或协议层的显式附件变为 candidate | 请求输入归一化完成后 |
入口不得对整段 prompt 做 URL/base64 猜测。普通文本保持普通文本。
### 8.2 工具返回链路
PR #7484 已经让内置工具、MCP 和 Extension 的模型可见结果先归一化为
response parts,再由共享 helper 处理。Core scheduler 与 ACP `Session.runTool()`
各有物理调用点,但都处理模型 ToolCall 的同一个逻辑结果边界。文件识别沿用这个
架构:
1. 各来源 adapter 在既有转换过程中,同时产出 `mediaCandidates`;
2. Core scheduler 与 ACP `Session.runTool()` 都在 normalized tool result
形成后调用同一个识别 helper;
3. `fixed_policy` 由 owning orchestrator 在同一 Tool 输出逻辑边界消费并调用该
helper,不再进入上述公共漏斗;
4. model/client MediaPolicyTool 的原始 producer artifacts 由公共漏斗中的 policy
bridge 分支转换为候选,不再由通用 artifact adapter 重复提取;hook artifacts
仍按普通显式候选处理;
5. 候选为空时直接跳过,不运行 sniff/probe;
6. 识别结果登记为 `RecognizedMediaResource`,同时生成配置控制的模型可见 metadata
投影;
7. 本文不改变媒体 `responseParts` 如何序列化给 Provider,后续由 policy 编排和
delivery 设计消费该资源与投影。
具体来源映射:
| 来源 | 候选来源 | 不能做的事 |
| --------- | --------------------------------------------- | ------------------------------------- |
| Builtin | 明确媒体 artifact、typed part | 枚举所有 `resultFilePaths` 并逐个识别 |
| MCP | image、audio、blob、resource、`resource_link` | 先把 link 变成文本再从文本反推 |
| Extension | 统一 tool result 中的 typed part 或 artifact | 在 extension 分支复制识别器 |
| Skill | Skill/工具显式发布的媒体 artifact | 扫描 Skill Markdown 中的任意链接 |
现有 `processToolResultImages` 仍承担 Vision Bridge 的既有职责。文件识别与它共享
“归一化后统一处理”的位置,但不能混入其模型上传或图像转写逻辑。
### 8.3 Metadata extraction 与 model visibility
每个识别入口都只生成一份完整内部 metadata。`metadata.modelVisibility` 从该事实对象生成
模型可见投影,不反向控制 probe:
- model visibility 字段数组为空时,只隐藏模型 metadata,不关闭内部提取;
- fixed policy 始终读取完整内部 metadata;
- probe 或 token 估算所需字段不可得时保留对应状态,供 Fixed Policy condition
产生 `unavailable`;
- `resourceId` 和 `mediaType` 始终保留在模型可见资源信封;
- `localPath` 和鉴权 header 不允许进入 model visibility;
- 首版 `source` 只用于内部 provenance,不提供 source visibility 配置;
- 新 policy artifact 重新进入识别服务后,使用同一套 model visibility 配置。
## 9. 失败语义
| 场景 | 结果 |
| ---------------------------------- | ------------------------------------------- |
| 候选内容不是图片、音频或视频 | `not_media` |
| 文件不存在、权限拒绝、不是普通文件 | `failed` |
| URL 预检确认不是媒体 | `not_media`,不完整下载 |
| URL 预检证据不足 | `failed/sniff_failed`,不完整下载 |
| URL 已知或实际大小超过配置许可阈值 | `approval_required` |
| 用户拒绝大文件下载 | 终止并清理 `.part` |
| inline decoded bytes 超过配置上限 | `failed/resource_limit` |
| sniff 成功、probe 不可用 | `recognized` + `probe.status = unavailable` |
| sniff 成功、probe 只得到部分字段 | `recognized` + `probe.status = partial` |
| probe 超过配置 timeout | 保留身份,probe 标为 partial 或 unavailable |
| 声明 MIME/扩展名与内容不一致 | 采用 detected MIME,并记录 warning |
| 同一候选被多个入口重复提交 | 允许按内容哈希复用识别结果 |
单个候选失败不应让同批其他候选丢失结果。`recognizeAll` 保持输入顺序,并为每个
候选返回独立状态。
## 10. 安全与资源约束
- 复用 Qwen Code 现有的文件访问、workspace trust、网络访问和用户确认机制;
- 只读取普通文件,不读取 device、FIFO 或 socket;
- URL 每次 redirect 后重新校验目标,不允许通过 redirect 绕过网络规则;
- 鉴权 header 仅用于当前读取,不写入 metadata、日志或内容身份;
- base64、data URI 和 bytes 只接受结构化字段,不尝试从任意文本中提取;
- 解码、预检和下载均使用流式或有界缓冲,禁止把大文件整体复制到内存;
- 临时文件只能在成功原子重命名后暴露,失败和拒绝路径清理 `.part`;
- 内容哈希缓存若后续实现,只能用 size、mtime、inode 等作为命中提示,命中失效后
必须重新计算,不能把可变属性当成稳定身份。
## 11. 与当前代码的对应关系
本设计是对现有链路的收敛,不新建平行产品:
- `packages/core/src/utils/fileUtils.ts` 当前的路径解析、`FileType` 和本地内容读取
继续作为文件入口,但媒体最终类型从扩展名/MIME 判断迁移到统一 sniff;
- `packages/cli/src/ui/components/InputPrompt.tsx` 与
`packages/cli/src/ui/hooks/atCommandProcessor.ts` 保留 TUI/非交互输入语法,
只在解析完成后产生候选;
- `packages/cli/src/acp-integration/session/Session.ts` 保留 ACP 协议解析,
避免 resource link 在识别前丢失结构;
- `packages/core/src/tools/mcp-tool.ts` 和 `mcp-resource-content.ts` 负责把 MCP
typed content 转为候选,不再把声明 MIME 当作识别结论;
- `packages/core/src/tools/tools.ts` 的 `ToolArtifact` 是工具显式媒体资源的主要
承载方式;通用 `resultFilePaths` 继续服务原有文件规则,不被扩大为媒体扫描器;
- `packages/cli/src/config/settingsSchema.ts` 和 `packages/cli/src/config/config.ts`
加载顶层 `omni.ingestion`,执行专用结构与范围校验后传入 Core `Config`;
- `packages/core/src/config/config.ts` 保存不可变的 normalized Omni 配置并提供只读
getter;识别结果记录 resolved ingestion config hash;
- `packages/core/src/core/coreToolScheduler.ts` 中 `convertToFunctionResponse`
之后,以及 ACP `Session.runTool()` 形成 response parts 之后,是同一逻辑触发点在
两条执行链路中的物理接入位置;二者必须调用同一个识别 helper;
- `packages/core/src/services/visionBridge` 保持既有职责,不承载通用文件识别。
识别成功后的固定 policy、模型 Tool、衍生物回流与 DashScope 投递由
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)
继续定义。
建议把新服务放在:
```text
packages/core/src/services/media-recognition/
```
目录内部可以按 locator 解析、sniff 和 probe 组织私有文件,但只对外导出服务、
候选类型和结果类型,避免形成多个需要调用方自行编排的模块。
## 12. 可验证性与验收标准
### 12.1 统一识别
- 同一媒体分别以 path、URL、base64、data URI 和 bytes 输入,最终
`mediaType`、`detectedMimeType`、技术元数据和 SHA-256 一致;
- 将图片改成错误扩展名并声明错误 MIME,仍按 magic bytes 识别,并产生冲突告警;
- GIF/APNG/animated WebP 返回 `mediaType = image` 且 `animated = true`;
- 普通文本文件和伪装成媒体的文件返回 `not_media`。
### 12.2 触发点
- TUI、非交互、ACP 的显式媒体输入均在输入归一化后进入同一个服务;
- Builtin、MCP、Extension 和 Skill 的显式媒体结果均在
normalized tool result 形成后进入同一个服务;
- 候选为空的工具结果不运行 sniff 或 probe;
- 工具文本中出现 URL、路径或 base64 时不触发识别;
- MCP `resource_link` 在退化为文本前被保留为结构化候选;
- 不通过 `resultFilePaths` 扫描工具产生的所有文件。
### 12.3 URL 与大文件
- 默认配置下,已知 101 MiB 的媒体 URL 在完整下载前请求一次用户许可;
- 将许可阈值调低后,下载行为使用新阈值且 resolved config hash 发生变化;
- 缺少或伪报 `Content-Length` 的响应在继续写入将超过配置阈值时暂停并请求许可;
- 拒绝后 `.part` 被清理;允许后可继续,Range 不可用时能安全重启;
- 非媒体 URL 只进行有界预检,不完整下载;
- redirect 后重新执行安全和大小检查;
- 下载结果使用本地完整文件重新 sniff/probe/hash。
### 12.4 配置与 metadata 投影
- inline decoded bytes 超过 `maxDecodedBytes` 时返回 `failed/resource_limit`;
- sniff 读取不超过 `sniffMaxReadBytes`;
- probe 使用配置的 timeout、probeSizeBytes 和 analyzeDurationUs,并记录实际值;
- 音频 10 秒的 `estimatedTokenCount` 为 70;
- 静态图片和视频严格按原始资源技术 metadata 及 `32 * 32 * 2` 公式向上取整;
- 视频缺失容器总帧数时按 `duration * frameRate` 推导 frameCount;
- 缺少 duration/width/height/frameRate 等必需字段时 token estimate 标为
unavailable,不伪造数值;
- `estimatedTokenCount` 只存在于 `tokenEstimate` 一处,`metrics` 不重复投影;
- model visibility allowlist 为空时,fixed policy 可用的内部 metadata 不减少;
- `resourceId` 和 `mediaType` 始终可见,`localPath` 和鉴权 header 始终不可见;
- 不支持的 model visibility 字段、非法超时和高于 100 MiB 的下载许可阈值在启动时失败。
- scope 合并时 model visibility 字段数组整体替换,未信任 workspace 的 ingestion 配置不
生效。
### 12.5 降级
- 启动时缺少 `ffmpeg`/`ffprobe` 直接启动失败并给出明确报错,不进入无 probe 的
降级运行;
- 单个损坏媒体的 probe 失败不影响同批其他候选;
- 无交互入口遇到大文件时返回 `approval_required`,不自行授权。
## 13. 待实现阶段确认的细节
以下问题不影响本架构边界,在进入实现计划时再确定:
- 初版完整支持的 image/audio/video magic bytes 与容器矩阵;
- `ffmpeg`/`ffprobe` 的具体分发方式(系统依赖 + 启动检查,或随实验分支捆绑
`ffmpeg-static` 一类静态二进制);两种方式都必须满足"缺失即启动失败";
- 内容哈希与 probe 结果的缓存实现。
受管本地文件的目录布局、staging/quarantine、保留周期和垃圾回收由
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md) 单独定义。
这些选择不得改变已经确定的约束:**显式候选、统一漏斗、内容证据优先、基于
原始资源的可追溯 token 估算,以及内部 metadata 与模型可见字段分离**。

View file

@ -0,0 +1,961 @@
# Omni 多模态 Memory 架构设计
## 状态
- 状态:Draft
- 范围:Qwen Code 实验分支中的图片、音频、视频文件级 Memory
- 前置设计:
[多模态文件识别与元数据提取架构设计](./2026-07-29-multimodal-file-recognition-and-metadata.md)、
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)
- 配套设计:
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md)
- 基线:`origin/main`,核对至 `2ce9da85bd99`
## 1. 背景
文件识别阶段已经把用户输入、工具结果、URL、base64 和 bytes 归一为带有完整
SHA-256 与技术 metadata 的媒体资源;Policy 阶段又可以从这些资源产生音轨、切片、
关键帧、转写、OCR、caption 和 summary 等结果。
如果这些事实只存在于当前 Tool 返回或当前模型上下文中,后续调用无法稳定回答:
- 当前文件已经提取过哪些 metadata;
- 哪些衍生物来自哪个文件版本、哪个 policy 和哪段范围;
- 一段转写、OCR 或 summary 覆盖了文件的哪些部分;
- 当前问题是否已有可复用结果,还是仍需调用某个 policy;
- 原文件发生变化后,哪些旧结果已经不应默认召回。
因此,本设计在现有 Qwen Code 识别服务、Policy Tool 和请求构造链路上增加一个
文件级的结构化 Memory。它不是新的产品、独立平台或 Agent 自主记忆系统;它只把
Harness 已经确定的文件事实和成功 policy 结果组织成可追溯、可召回的文件内图。
## 2. 目标与非目标
### 2.1 目标
- 只在两个确定的 Harness 生命周期边界收集 Memory:`FileRecognized` 与
`OmniPolicySucceeded`;
- 建立文件、文件版本、policy 执行、媒体衍生物和文本结果之间的文件内关联;
- 保留来源、完整内容哈希、处理参数、作用范围、覆盖度和 producer lineage;
- 对成功 policy 的全部有效输出进行原子登记,不留下半套可召回结果;
- 支持 Agent 主动调用 Tool 召回,以及 Harness 通过 side query 被动召回;
- 两种召回模式复用同一查询服务和返回协议,并通过 `omni.memory.recall.mode`
切换;
- 默认只召回当前文件版本的记录,文件变化后自动隔离旧版本结果;
- 允许后续 policy 直接使用召回到的既有媒体衍生物,而不向模型暴露真实本地路径;
- 记录 resolved Omni 配置、Tool 版本和最终参数,支持实验复现与训练数据追溯。
### 2.2 非目标
初版不设计:
- `MediaTurnCompleted` 或其他轮次结束后的总结触发;
- `MediaTurnTrace`,以及“模型在本轮实际看过什么”的完整轨迹;
- 从主 Agent 最终回答中抽取结论并写入 Memory;
- 允许 Agent 通过 Tool 直接创建、修改或删除 Memory;
- Agent 驱动的 memory extraction、dream 或 consolidation;
- 跨文件语义关联、相似文件合并或全库内容搜索;
- `/learn` command 的任何接入;
- computation 调度;
- 具体存储后端、长期保留周期和垃圾回收实现。
本设计中的 side query 只选择已经存在的记录,不能生成新结论并写回 Memory。
## 3. 核心决策
### 3.1 初版只有两个收集触发点
初版只允许以下两类事实进入媒体 Memory:
1. `FileRecognized`:统一识别器已经确认媒体类型、计算完整 SHA-256,并产出可用的
文件 metadata;
2. `OmniPolicySucceeded`:一个 MediaPolicyTool 已成功执行,所有返回产物已经完成
descriptor、路径、类型、内容和 lineage 校验。
这里的“触发点”是语义生命周期边界,不要求新增全局 EventBus。实现可以是识别服务
和 policy orchestrator 对同一个 `MediaMemoryService` 的显式方法调用:
```ts
await mediaMemory.recordFileRecognized(payload);
await mediaMemory.commitPolicySucceeded(payload);
```
这样可以沿用现有调用链、事务和错误传播,不建立另一套异步消息平台。
### 3.2 Harness 独占写入,Agent 只读
Memory 的写入事实必须来自可验证的 Harness 事件,而不是模型判断:
- Agent 可以调用 MediaPolicyTool;Tool 成功后由 Harness 触发
`OmniPolicySucceeded`,间接增加 Memory;
- Agent 可以调用召回 Tool 读取既有记录;
- Agent 不能提交任意文本让 Memory 保存;
- side query 只能返回候选记录 ID,不能生成新 Memory 条目;
- 主 Agent 的回答、推理或猜测不自动进入 Memory。
因此,初版不需要 `MediaTurnTrace`。只有未来要保存“本轮采用了哪些证据、模型形成了
什么结论”时,才需要引入轮次级 trace 和第三个收集触发点。
### 3.3 初版只建立文件内图
每个原始文件形成一个独立 root graph。它的 metadata、policy 执行、衍生媒体和文本
结果都挂在这个 root 下;初版不在两个 root file 之间建边。
即使两个文件的完整 SHA-256 相同,也只能复用底层识别或 policy 缓存,不能合并
它们的 `fileId` 或来源关系。这样可以保留“用户从哪里得到这个文件、在哪个上下文中
使用它”的独立事实,避免内容相同但权限、来源或语义不同的资源被错误混为一体。
### 3.4 文件身份与内容版本分离
`fileId` 表示一个逻辑文件,`fileVersionId` 表示该逻辑文件的一份确定字节内容。
初版只在以下场景确认是同一个逻辑文件:
- 上游携带已知的稳定 `fileRef`,或者同一 project 内再次出现相同来源类型和规范化
locator(例如同一个本地路径或同一个 URL);
- URL、base64 或 bytes 本地化过程显式把来源和受管本地文件登记为同一资源;
- policy 产物通过 `parentRef`、`rootFileId` 和 `producedBy` 明确建立 lineage;
- 同一受管资源在当前系统内被再次解析,且携带既有持久引用。
不同路径、不同 URL,或没有稳定 locator/lineage 的独立工具结果,即使名称、大小、
ETag 或 SHA-256 相同,也作为另一个 `File` 处理。locator 只确定逻辑 File,不能证明
其内容版本没有变化。
在已经确认的同一 `fileId` 内,只有完整文件 SHA-256 相同才能确认是同一版本:
- SHA-256 相同:复用现有 `FileVersion`;
- SHA-256 不同:创建新版本,并把它设为当前版本;
- URL、文件名、大小、mtime、ETag、声明 MIME 和部分哈希都不能单独证明版本相同。
### 3.5 Metadata 是版本属性,不强制拆成节点
技术 metadata 直接保存在 `FileVersion` 上即可。只有具有独立来源、范围或 producer
语义的 policy 结果才成为独立节点。这样既能查询宽高、时长、流信息和 probe 状态,
又不会把每个标量字段膨胀成图节点。
## 4. 总体架构
```mermaid
flowchart TD
U["用户输入或普通工具结果"] --> R["统一媒体识别"]
P["MediaPolicyTool 原始产物"] --> R
R --> F{"资源来源"}
F -- "user / tool" --> FR["FileRecognized 直接提交"]
F -- "policy" --> ST["按 invocation 暂存识别结果"]
PT["Policy Tool 执行成功"] --> V["校验全部 required/returned outputs"]
ST --> V
V --> PS["OmniPolicySucceeded 原子提交"]
FR --> G["文件内 Media Memory Graph"]
PS --> G
AQ["Agent 主动调用 Recall Tool"] --> RS["共享 Recall Service"]
SQ["Harness side query 选择已有 entryId"] --> RS
G --> RS
RS --> RR["统一 RecallResult"]
RR --> AQ
RR --> SQ
```
写链路只由两个触发点驱动。主动召回和 side-query 召回是读链路,不构成新的收集
触发点。
## 5. 文件内逻辑数据模型
### 5.1 概念图
```text
File
└── FileVersion
├── technical metadata
├── PolicyExecution
│ ├── final arguments / input scope / config hash
│ └── output references
├── DerivedMedia (另一个带 lineage 的 File/FileVersion)
│ └── subsequent PolicyExecution
└── PolicyResult
├── transcript
├── OCR
├── caption
└── summary
```
媒体衍生物在物理模型中仍使用 `File + FileVersion`,从而复用统一识别、版本和
metadata 契约;`DerivedMedia` 只是它在父文件图中的角色。文本结果使用
`PolicyResult`,不伪装成媒体文件。
### 5.2 持久 ID 与 session resourceId
当前 Policy 设计中的 `resourceId` 是会话内 opaque handle,用于让模型调用 Tool,
不能作为跨 session 的持久主键。Memory 使用独立 ID:
```ts
type MediaFileId = string;
type MediaFileVersionId = string;
type MediaMemoryEntryId = string;
type PolicyExecutionId = string;
```
召回某个仍可访问的媒体文件或衍生物时,Harness 把持久 `fileVersionId` 重新绑定到
当前 session 的 `MediaResourceRegistry`,再返回新的或已存在的 `resourceId`。
模型只看到 `resourceId` 和持久 evidence ref,不看到本地路径。
### 5.3 File 与 FileVersion
以下接口是逻辑契约,不预先绑定 SQLite、JSON 或其他存储格式:
```ts
interface MediaFileRecord {
fileId: MediaFileId;
rootFileId: MediaFileId;
origin: 'user' | 'tool' | 'policy';
currentVersionId: MediaFileVersionId;
createdAt: string;
}
interface MediaFileVersionRecord {
fileVersionId: MediaFileVersionId;
fileId: MediaFileId;
sha256: string;
mediaType: 'image' | 'audio' | 'video';
metadata: MediaFileMetadata;
source: MediaSourceProvenance;
recognition: {
ingestionConfigHash: string;
detectorVersion: string;
probeBackend?: string;
probeStatus: 'complete' | 'partial' | 'unavailable';
};
parentVersionId?: MediaFileVersionId;
producedByExecutionId?: PolicyExecutionId;
createdAt: string;
}
```
`source` 保留来源类型和经过脱敏的 locator/protocol 信息。鉴权 header、token 和真实
受管路径不进入模型可见协议。
### 5.4 PolicyExecution
```ts
interface MediaPolicyExecutionRecord {
executionId: PolicyExecutionId;
invocationId: string;
sourceVersionId: MediaFileVersionId;
rootFileId: MediaFileId;
executionOrigin:
| {
kind: 'fixed_policy';
policyId: string;
stage: 'preprocessing' | 'transport_guard';
}
| { kind: 'model' | 'client' };
toolName: string;
toolVersion: string;
finalArguments: Record<string, unknown>;
inputScope: MediaScope;
omniConfigHash: string;
outputRefs: MediaMemoryEntryId[];
startedAt: string;
completedAt: string;
}
```
Memory 记录的是通过 Tool 原生 schema、`defaultArguments`、`lockedArguments` 和
runtime resolver 之后的最终参数,不只记录用户原始配置或模型原始参数。
### 5.5 Scope、channel 与 coverage
同一结果必须明确“它描述了文件的哪一部分”和“覆盖了哪些信息通道”:
```ts
interface MediaScope {
temporal?: { startMs: number; endMs: number };
spatial?: {
x: number;
y: number;
width: number;
height: number;
unit: 'normalized';
};
frameRange?: { start: number; end: number };
streamIndexes?: number[];
audioChannels?: number[];
}
type MediaChannel =
| 'technical_metadata'
| 'visual'
| 'acoustic'
| 'speech_text'
| 'onscreen_text';
interface MediaCoverage {
mode: 'complete' | 'continuous' | 'sampled' | 'partial' | 'summary';
scope: MediaScope;
sampleCount?: number;
sampleRate?: number;
}
```
空 `MediaScope` 表示整个文件版本。示例:
- 完整音频转写:`speech_text + complete + {}`;
- 视频 15s 到 30s 的切片:`visual/acoustic + continuous + temporal`;
- 每 10 秒抽一帧:`visual + sampled + temporal + sampleRate`;
- 图片局部 OCR:`onscreen_text + partial + spatial`;
- 全片 summary:`visual/acoustic + summary + {}`。
### 5.6 统一 Policy 输出记录
Memory 不解析 `llmContent` 寻找结果。每个可持久化输出必须来自
`PolicyArtifactBatch`,并由 Tool descriptor 和 bridge 归一为:
```ts
interface NormalizedPolicyOutput {
outputId: MediaMemoryEntryId;
kind: 'derived_media' | 'policy_result';
role: string;
artifactRef?: {
storage: 'managed' | 'workspace';
managedId?: string;
workspacePath?: string;
mimeType: string;
sizeBytes: number;
};
inlineText?: string;
/** 有损/表达变换输出随附的处理与降质披露文本(见 Policy 设计 3.2)。 */
disclosure?: string;
scope: MediaScope;
channels: MediaChannel[];
coverage: MediaCoverage;
parentVersionId: MediaFileVersionId;
producedByExecutionId: PolicyExecutionId;
}
```
初版至少需要稳定表达这些 role:
- 文本结果:`transcript`、`ocr`、`caption`、`summary`;
- 媒体结果:`keyframe`、`clip`、`extracted_audio`;
- 其他实验产物由 MediaPolicyTool descriptor 声明稳定 role,不能依赖展示标题猜测。
媒体结果必须使用 artifact 引用并重新经过文件识别。小型 UTF-8 文本可以内联保存;
超过 `collection.maxInlineTextBytes` 的文本必须由 policy 输出受管文本 artifact。两者都
必须带 scope、channel、coverage 和 producer,不能只存一段无来源文本。
## 6. 收集触发点一:FileRecognized
### 6.1 触发条件
只有识别结果满足以下条件时才产生 `FileRecognized` payload:
- `mediaType` 已确定为 image、audio 或 video;
- 完整文件 SHA-256 已计算;
- 本地化和安全检查已完成;
- 公共 metadata 可用;
- probe 即使降级,也有明确的 `complete`、`partial` 或 `unavailable` 状态。
`not_media`、`approval_required`、下载失败、hash 失败或无法建立受控引用的候选不写入
Memory;它们只进入识别日志或用户可见错误。
### 6.2 写入内容
一次成功触发至少登记:
- 逻辑 `File` 和当前 `FileVersion`;
- 来源类型、显式 lineage 和本地化关系;
- 完整 SHA-256;
- detected/declared/extension MIME 与冲突告警;
- 图片、音频或视频技术 metadata;
- versioned token estimate、估算方法和实际估算输入;
- probe 状态、后端和 resolved ingestion config hash;
- 如果是 policy 媒体产物,则登记 parent、root 和 producer invocation。
### 6.3 普通来源直接提交
用户输入和普通工具结果没有等待中的 policy 事务。识别成功后可以立即、幂等地提交
File 与 FileVersion。相同事件重试不会生成重复版本。
### 6.4 Policy 媒体产物先暂存
Policy Tool 可能声明多个必须同时成立的输出。例如一次视频处理同时产生音轨、
关键帧和索引文件。不能在第一个媒体完成识别时就把它暴露为可召回节点,否则后续
必需产物失败会留下孤儿或半套结果。
因此,policy-origin 的 `FileRecognized` 结果按 `invocationId` 进入 staging:
```text
原始 Tool artifacts
→ 每个媒体 artifact 识别 / hash / probe
→ staging[invocationId]
→ 全部 descriptor 与文本产物校验
→ OmniPolicySucceeded 原子提交
```
staging 不是第三个触发点,也不是持久可召回图;它只是
`OmniPolicySucceeded` 事务的准备状态。
## 7. 收集触发点二:OmniPolicySucceeded
### 7.1 固定调用与模型调用共用
所有 policy 都以 Qwen Code Tool 实现;Fixed Policy 与 Transport Guard policy 也
通过 `execute` 运行。因此,固定调用、模型 ToolCall 和 direct client 调用在成功后
都进入同一个 `OmniPolicySucceeded` 逻辑边界,不为不同调用来源复制 Memory 写入代码。
### 7.2 成功门槛
只有同时满足以下条件才允许触发:
- Tool 返回没有 `error`,也没有取消或 timeout;
- descriptor 中所有 required output 已出现;
- 所有实际返回 output 都命中 descriptor;
- 所有 artifact 都通过 managed/workspace path、文件存在性和权限校验;
- 所有媒体 artifact 都已完成识别、完整 hash 和 probe 状态登记;
- 所有文本 artifact 都可读取、编码有效且未越过对应资源预算;
- 每个 output 的 role、scope、channels、coverage 和 producer 已归一化;
- source version、最终参数、execution origin、policy ID、stage 和配置哈希都已确定。
Policy 文档定义的 delivery `include` 与 `retain` 只决定是否进入当前模型输入,不决定
是否进入 Memory。一个成功 invocation 的有效输出都会被登记;`source: omit` 也只
影响投递,不删除 source 的 Memory。
### 7.3 原子提交内容
一次事务同时写入:
1. `PolicyExecution`;
2. source/root/version 关系;
3. staging 中已经识别的媒体衍生文件与版本;
4. transcript、OCR、caption、summary 等 policy-native 文本结果;
5. execution 到全部 output 的 producer edge;
6. parent、root、scope、channel、coverage 和配置 provenance。
任一步失败都不提交本 invocation 的 active Memory。已产生的文件按 Policy 设计进入
quarantine/cleanup;失败记录可以进入独立运行日志,但不能伪装成可召回 Memory。
### 7.4 禁止从模型文本反推产物
以下内容不能成为 `OmniPolicySucceeded` 的输出来源:
- `llmContent` 中提到的路径、URL 或摘要;
- `returnDisplay` 中的 Markdown;
- PostToolUse hook 追加的 artifact;
- 对任意返回对象进行递归扫描发现的字段。
Memory 只消费 Policy 文档定义的原始 `PolicyArtifactBatch`。这样能证明 producer,
也不会把 hook 或 UI 产物错误关联到 MediaPolicyTool。
## 8. 图关系与查询边界
初版需要支持以下关系:
| 关系 | 含义 |
| ----------------- | -------------------------------------- |
| `HAS_VERSION` | File 拥有不可变 FileVersion |
| `CURRENT_VERSION` | File 当前默认召回的版本 |
| `DERIVED_FROM` | 衍生媒体版本来自某个父版本 |
| `PRODUCED_BY` | 衍生媒体或 PolicyResult 由某次执行产生 |
| `EXECUTED_ON` | PolicyExecution 的输入文件版本 |
| `HAS_OUTPUT` | PolicyExecution 的结构化输出 |
所有遍历必须被 `rootFileId` 限制。查询一个文件可以沿 lineage 查看它的祖先、后代、
执行和结果,但不能自动跳到另一个 root graph。相同 hash 的缓存映射不是图关系。
## 9. Recall 设计
### 9.1 一个 Recall Service,两种触发方式
主动与被动召回共享:
- 同一个项目作用域和权限检查;
- 同一个 file/version 解析器;
- 同一个查询、过滤和预算逻辑;
- 同一个 `MediaMemoryRecallResult`;
- 同一个 current-version 默认规则;
- 同一个持久 fileVersion 到 session resourceId 的绑定器。
两者的差异仅在于谁发起查询:
- `active`:Agent 调用 `omni_recall_media_memory` Tool;
- `sideQuery`:Harness 在构造相关模型请求前运行 side query,自动选择已有记录。
配置 `mode` 用于实验切换。`active` 模式不自动注入,`sideQuery` 模式不向模型暴露
主动召回 Tool,避免一次实验同时混入两种策略。
### 9.2 主动召回
主动 Tool 最小输入包括:
```ts
interface MediaMemoryRecallRequest {
resourceIds: string[];
query: string;
kinds?: Array<'metadata' | 'derived_media' | 'policy_result' | 'execution'>;
roles?: string[];
scope?: MediaScope;
includeHistoricalVersions?: boolean;
limit?: number;
}
```
`resourceIds` 必须属于当前 session 已授权资源。服务先解析为持久 file/version,再只在
对应 root graph 内查询。模型不能用任意路径、hash 或 fileId 扫描整个项目。
召回返回的可用媒体衍生物会绑定出当前 session 的 `resourceId`,Agent 随后可把它传给
另一个 MediaPolicyTool。召回本身不复制文件、不创建新 File,也不触发 policy。
#### 9.2.1 "按路径/内容 hash 查询"的一致解读
issue #8188 同一条验收项里写了"按路径/内容 hash 查询"与"返回中永不暴露真实本地路径"。
按本设计 §5.2/§15 的路径隔离不变量,二者的一致解读是**按调用方分层**:
| 调用方 | 允许的入口 | 理由 |
| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 模型(active Tool / passive selector) | 仅 session `resourceId` | 句柄是不可伪造的能力凭证:模型只能问本会话真的投递给它的媒体。放开路径入参会让媒体文件里的注入内容驱使模型探测任意路径,而召回返回的是转写正文——典型 confused-deputy 提权;即使 miss,"有/无记忆"本身也是存在性泄露 |
| Harness(受管管线内部) | 路径 / 内容 hash | 不经模型,无提权面。已由 `findBindingBySha256` 提供并被反应式降质阶梯使用 |
因此"系统能按路径/hash 找到记忆"这一能力是满足的,只是不向模型暴露。
代价与已知缺口:句柄只在投递时签发,而投递要求文件存在——所以在新 session 中,被
删除或移动的文件无法再获得句柄,其记忆暂时不可达。补法不是放开模型的路径入参,而是
在**授权层**补:让 `@` 引用一个"记忆里有、磁盘上没了"的文件也能凭记录的
`fileRef`+`sha256` 签发句柄(用户的 `@` 引用即授权)。留作后续阶段。
### 9.3 被动 side-query 召回
被动召回只处理当前请求已经显式引用并完成识别的文件。它不从普通文本猜路径,也不
跨文件搜索项目中的其他媒体。
流程为:
```text
当前请求的显式 resourceIds
→ 解析为当前 FileVersion
→ 从对应文件内图生成有界 candidate manifest
→ runSideQuery(JSON schema)
→ side query 只返回 candidate entryId
→ 校验 ID 必须属于 manifest
→ Harness 按统一协议物化结果并注入主模型请求
```
candidate manifest 可以包含 entry ID、kind、role、scope、channels、coverage、简短
description 和 producer 摘要,但不把原始媒体、完整大文本、本地路径或密钥发送给
selector。
side query 不允许返回自由文本结论,也不能要求创建新条目。未知 ID、跨 root ID、
超出 `maxSelectedEntries` 或 schema 不合法的结果整体拒绝。
为了保证实验条件确定,选中结果必须在相关主模型请求发送前完成注入;不能在模型已
开始回答后再把迟到结果塞进后续 ToolResult turn。超时或 selector 失败时本次按空召回
继续,并记录明确的失败原因和配置哈希。
### 9.4 最小返回协议
```ts
interface MediaMemoryRecallResult {
status: 'hit' | 'partial' | 'miss';
files: Array<{
fileId: MediaFileId;
fileVersionId: MediaFileVersionId;
current: boolean;
mediaType: 'image' | 'audio' | 'video';
}>;
entries: Array<{
entryId: MediaMemoryEntryId;
kind: 'metadata' | 'derived_media' | 'policy_result' | 'execution';
role?: string;
content?: string;
/** content 是 memory 所存文本的前缀时为 true(被 recall.maxTextChars 截断)。
* coverage 讲的是"处理过什么",所以一条被截断的转写仍合法地报 complete;
* 没有这个标记,模型看到前缀 + 完整覆盖 + 无 gap,就会对它从未读到的后半段
* 音频作答。默认值本身就会撞上:maxTextChars(24000 字符)小于收集期界
* (65536 字节),任何长转写都在读取期被切。 */
contentTruncated?: boolean;
resourceId?: string;
scope: MediaScope;
channels: MediaChannel[];
coverage: MediaCoverage;
evidenceRefs: Array<{
fileVersionId: MediaFileVersionId;
executionId?: PolicyExecutionId;
}>;
provenance: {
toolName?: string;
toolVersion?: string;
policyId?: string;
stage?: 'preprocessing' | 'transport_guard';
omniConfigHash: string;
};
}>;
gaps: Array<{
scope: MediaScope;
channels: MediaChannel[];
reason: 'not_processed' | 'partial_coverage' | 'artifact_unavailable';
}>;
nextPolicyActions?: Array<{
toolName: string;
resourceId: string;
arguments: Record<string, unknown>;
reason: string;
}>;
/** 匹配总数,仅当条目预算把列表截短时出现。没有它,一页被截断的结果与穷尽结果
* 无法区分:真实审计在 limit: 12 下读到 6 段 clip,就得出"从未抽过关键帧",
* 而库里有 72 条——读的人没有说谎,它只是无从知道自己看到的是一页。 */
matchedEntries?: number;
}
```
`partial` 表示已有相关结果,但请求范围或 channel 仍存在缺口。`nextPolicyActions` 只
提供可执行建议,由 Agent 决定是否调用;side query 和 Recall Service 都不能自动执行
这些 Tool。
### 9.5 Current-version-first
默认查询只返回当前 `FileVersion` 及其派生图。历史版本记录可以为 provenance 保留,
但只有主动请求或配置允许时才进入候选。
如果当前版本没有结果、旧版本有结果,默认返回 `miss` 或带明确历史提示的 `partial`,
不能把旧结果当成当前文件事实。这样文件更新后不会静默复用失效 transcript、OCR、
关键帧或 summary。
## 10. 顶层 Omni 配置
Memory 配置直接放在现有实验配置的 `omni.memory` 下,不并入 Qwen Code 当前的
managed auto-memory 配置,也不增加独立配置文件、环境变量或 CLI flag。初版没有
`omni.enabled` 或 `memory.enabled`;收集始终按两个确定触发点执行,默认 recall 模式
为 `active`。
以下 JSONC 展示初版需要的细粒度实验控制。每个字段都在 scope 合并、默认补齐和
校验后进入 resolved Omni config hash。
```jsonc
{
// Omni 实验配置的唯一顶层入口;不增加总开关。
"omni": {
// 配置结构版本,用于记录实验并兼容后续 schema 调整。
"schemaVersion": 1,
// 文件级多模态 Memory 的收集与召回配置。
"memory": {
// 两个固定收集触发点共用的持久内容边界。
"collection": {
// 文本不超过该字节数时可直接存入 PolicyResult;更大的文本必须由
// policy 产出受管 text artifact,不能静默截断后保存。
"maxInlineTextBytes": 65536,
},
// 控制已收集记录如何暴露给模型,不影响底层事实收集。
"recall": {
// active 由 Agent 调用 Recall Tool;sideQuery 由 Harness 自动选择并注入。
"mode": "active",
// 单次统一 RecallResult 最多返回的 entry 数量。
"maxEntries": 12,
// 单次返回给主模型的文本内容总字符预算;超过时按确定顺序裁剪并报告 gap。
"maxTextChars": 24000,
// 默认允许召回的节点类型;高优先级 settings scope 对该数组整体替换。
"kinds": ["metadata", "derived_media", "policy_result", "execution"],
// false 表示默认只召回当前文件版本;true 用于显式历史对照实验。
"includeHistoricalVersions": false,
// Agent 主动调用 Recall Tool 时的额外资源边界。
"active": {
// 一次 ToolCall 最多查询多少个当前 session 资源,防止无界批量扫描。
"maxFilesPerCall": 8,
},
// mode 为 sideQuery 时使用的 selector 参数。
"sideQuery": {
// null 表示沿用 runSideQuery 的 fast-model 优先选择;字符串可固定实验模型。
"model": null,
// selector 必须在该时间内返回;超时后本次空召回并继续主请求。
"timeoutMs": 30000,
// 发送给 selector 的候选 entry 上限;候选仍只来自当前显式文件。
"maxCandidateEntries": 100,
// selector 最多可以返回的合法 entryId 数量,且不能大于 maxEntries。
"maxSelectedEntries": 12,
// side query 的最大请求尝试次数;1 最便于控制实验成本和延迟。
"maxAttempts": 1,
},
},
},
},
}
```
### 10.1 配置校验
启动时至少校验:
- `mode` 只能为 `active` 或 `sideQuery`;
- 所有数量、文本和 timeout 预算必须为正数;
- `maxSelectedEntries <= maxEntries <= maxCandidateEntries`;
- `kinds` 只能包含已知节点类型且不能为空;
- `model` 为 `null` 或非空字符串;
- 未信任 workspace 的 Omni 配置不参与合并;
- 数组整体替换,不做 concat 或 union。
与前两篇设计一致,scope 优先级为
`SystemDefaults < User < trusted Workspace < System`。无效配置启动失败,不静默回退到
另一种召回模式。
## 11. 版本、失效与复用
### 11.1 文件变化
同一逻辑文件得到不同完整 SHA-256 时:
1. 创建新的不可变 `FileVersion`;
2. 更新 `CURRENT_VERSION`;
3. 旧版本及其执行和产物保留为历史 provenance;
4. 默认 recall 排除旧版本;
5. 新版本必须重新运行需要的 policy,不能只按路径或名称复用旧结果。
### 11.2 不合并不同文件
两个独立 `fileId` 即使 SHA-256 一致,也保留两个 File 和两套来源。允许复用的是:
- sniff/probe/hash 的计算缓存;
- 确定性 policy 结果及其底层 artifact;
- 受管文件的物理存储块。
复用时每个 File 仍保留自己的 root、source 和 lineage 引用,不能把一个文件的
权限或 provenance 泄漏给另一个文件。
#### 11.2.1 三层身份:为什么"不合并"与"不重复建节点"并不矛盾
issue #8188 的验收项写作"内容相同的两个文件不重复建节点(复用同一底层身份)",
与本节标题字面冲突。二者实际落在**不同层**,实现同时满足:
| 层 | 键 | 承载 | 满足 |
| ----------- | ------------------ | --------------------------------------------------------------- | ---------------------------------------- |
| Content | `sha256` | 物理对象(`objects/sha256/…`)、可复用的计算结果与 policy 派生物 | #8188「不重复建节点(复用同一底层身份)」 |
| File | locator | `CURRENT_VERSION`、source、provenance、权限/workspace 归属 | 本节「不合并不同文件」、#8189 版本隔离 |
| FileVersion | `(fileId, sha256)` | 二者的连接,不可变 | 11.1 版本链 |
关键约束:**身份键不能是内容**。若 `fileId = hash(content)`,则"内容变化"直接
产生另一个 `fileId`,11.1 的版本链(新 FileVersion → 更新 `CURRENT_VERSION` →
旧版本留作历史 → 默认排除旧版本)与 #8189 验收项"修改文件内容后旧衍生物不默认
召回"**都无法表达**——编辑文件会凭空出现无关节点,改回去则历史成环。因此逻辑文件
必须由跨内容稳定的 locator 定键。
于是"不重复付费"由 content 层承担:同字节的第二个文件不重跑任何工具(见 11.3 的
复用键与 `reusedExecutionId`),不重复存储字节(共享同一 `objects/` 对象),只额外
写几行属于自己的廉价元数据行。
派生物同样按 `(rootFileId, objectPath)` 定键而非仅 `objectPath`:两个 root 派生出
逐字节相同的产物时,若共享一个 File 节点,该节点的 `rootFileId` 只能属于先创建者,
既泄漏 lineage 又让第二个 root 的有界遍历(§8)够不到它。
### 11.3 Policy 结果复用键
可复用 policy 结果的复用键必须与 11.2 的跨文件复用语义一致,因此以**内容身份**
而不是 File 作用域的 version ID 为基础:
```text
source content sha256
+ tool name and implementation version
+ tool settings hash(影响结果的 Tool 级配置)
+ schema 校验后的 final arguments
+ input scope
+ resolved Omni config hash 中影响结果的部分
```
`fileVersionId` 是 File 作用域的主键,两个独立 File 的 version ID 必然不同;如果
把它放进复用键,11.2 允许的跨文件确定性复用将永远无法命中。以 sha256 为键则
同一份字节在任何 File 中出现都能复用底层计算,同时每个 File 仍写入自己的
PolicyExecution 与 provenance(记录 cache hit 与 `reusedExecutionId`),不共享
图节点。
相同 `invocationId` 重放必须幂等;独立 cache hit 可以记录一次明确的
`reusedExecutionId`/cache-hit 运行事实,但不能复制 FileVersion 或 PolicyResult
节点。精确 cache key 的字段裁剪在实现计划中确定,不能牺牲上述 provenance。
## 12. 事务与失败语义
| 场景 | Memory 行为 |
| ------------------------------------------- | ------------------------------------------------------ |
| 普通文件识别成功 | 幂等提交 File/FileVersion |
| probe 降级但类型、hash 和基础 metadata 可用 | 提交并记录 partial/unavailable |
| 下载拒绝、识别失败或非媒体 | 不写 Memory,只记录运行状态 |
| Policy Tool 返回 error、timeout 或取消 | 不触发 `OmniPolicySucceeded` |
| required output 缺失 | 整个 invocation 不提交 |
| 返回 artifact 未命中 descriptor | 整个 invocation 不提交 |
| 某个媒体 artifact 识别失败 | 丢弃该 invocation 的全部 staging 结果 |
| 文本 artifact 无法读取或编码非法 | 整个 invocation 不提交 |
| policy-origin staging 完成但进程中断 | 恢复时清理或重放,不对 recall 可见 |
| 相同事件重复投递 | 通过 event/invocation ID 幂等,不重复建节点 |
| side query 超时或返回非法 ID | 本次空召回,主请求继续并记录原因 |
| 主动 Recall Tool 参数或权限非法 | Tool 返回结构化错误,不扩大查询范围 |
| backing artifact 已清理或不可读 | 返回 `artifact_unavailable` gap,不返回失效 resourceId |
## 13. 存储与作用域要求
初版逻辑 store 必须满足:
- 以 project/workspace 为隔离边界,不跨 workspace 自动召回;
- 对 `FileRecognized` 提供幂等 upsert;
- 对 `OmniPolicySucceeded` 提供多记录原子事务;
- 支持按 `fileId`、current `fileVersionId`、root、role、scope 和 channel 查询;
- active graph 记录不能引用 staging 或 quarantined artifact;
- 持久引用与真实路径分离,返回协议不泄漏受管目录;
- artifact 生命周期不得早于仍引用它的 active Memory 记录;
- 可以保留历史版本,但默认索引必须把它们与 current version 分开。
本文不提前选择 SQLite、JSONL 或其他后端。后端选择必须证明上述事务、索引和恢复
语义,而不是反过来改变已确定的数据边界。受管文件的物理目录布局、staging 与
quarantine 的落盘形式、保留周期和垃圾回收由
[Omni 受管媒体存储设计](./2026-07-30-omni-managed-media-storage.md) 定义;本节的
"artifact 生命周期不得早于 active Memory 记录"是该设计必须满足的强约束。
## 14. 与当前 Qwen Code 源码的对应关系
本设计是现有 Qwen Code 能力上的增量开发:
- 文件识别设计新增的 `MediaRecognitionService` 在成功结果形成后调用
`recordFileRecognized`;policy-origin 结果携带 invocation ID 进入 staging;
- Policy 设计新增的 `PolicyArtifactBatch` 是 `OmniPolicySucceeded` 唯一产物入口,
不能消费合并后的 hook artifacts;
- `packages/core/src/tools/tools.ts` 当前 `ToolArtifact` 继续承载文件、URL、managed
ID、MIME、大小和 metadata;Memory bridge 在 descriptor 约束下把它归一为
`NormalizedPolicyOutput`;
- `packages/core/src/core/turn.ts` 当前 `ToolCallResponseInfo.artifacts` 只有合并后语义,
继续按 Policy 设计增加仅供内部使用的 `policyArtifacts` channel;
- `packages/core/src/core/coreToolScheduler.ts` 在原始 Tool 成功、hook artifact 合并前
捕获 policy 输出;固定调用和模型调用都复用这一成功边界;
- `packages/cli/src/acp-integration/session/Session.ts` 在 ACP 自己的 Tool executor 中
捕获相同 batch,再调用同一个 core Memory service;
- `packages/core/src/utils/sideQuery.ts#runSideQuery()` 已支持 JSON schema、超时信号、
model override 和结果校验,可以直接作为 passive selector 基础;
- `packages/core/src/memory/relevanceSelector.ts` 已验证“给模型有界 manifest、只允许
返回 manifest 内 ID”的 selector 模式,可以复用该约束方式,但不能复用其 Markdown
文件数据模型;
- `packages/core/src/core/client.ts` 当前 managed auto-memory 已有 recall 取消、请求
构造和 prompt 注入经验;媒体 side query 复用生命周期原则,但必须在关联主请求发送
前完成,不能采用迟到后再注入 ToolResult turn 的机会式语义;
- `packages/cli/src/config/settingsSchema.ts`、`packages/cli/src/config/config.ts` 与
`packages/core/src/config/config.ts` 继续加载顶层 `omni.memory` 并提供不可变 getter;
- 当前 `packages/core/src/memory/` 的 managed auto-memory 以 Markdown、Agent
extraction/dream 和用户/项目主题文件为核心,不作为结构化 media graph 的存储格式,
也不参与本设计写入。
建议把新增能力作为一个 Qwen Code Core service 放在:
```text
packages/core/src/services/media-memory/
```
目录内部可以包含 types、store、collector 和 recall,但对外只暴露一个
`MediaMemoryService` 以及 Recall Tool 所需的类型。它不是新的 daemon、MCP server、
进程、插件协议或独立配置系统。
## 15. 安全与权限
- 写入者只接受识别服务和 policy orchestrator 的 typed payload;
- Agent 与 side query 都没有写接口;
- active recall 只接受当前 session 已授权的 `resourceId`;
- side query 只处理当前请求显式引用的文件;
- project/workspace 隔离、workspace trust 和现有文件权限继续生效;
- 不向模型、selector、日志或 telemetry 暴露鉴权 header、token 或受管本地路径;
- 历史版本、quarantine 和已清理 artifact 不自动重新绑定为 session resource;
- 同 hash 缓存复用不能绕过来源权限和 workspace 隔离。
## 16. Observability 与实验复现
每次收集至少记录:
- 触发类型、event/invocation ID 和幂等结果;
- file/root/version ID 与 hash 的脱敏引用;
- source、parent、producer、tool、policy 和 execution origin;
- Tool 版本、final arguments hash、scope、channels 和 coverage;
- ingestion/processing/memory resolved config hash;
- staging、提交、回滚、cache hit 和 artifact availability 状态;
- 耗时、节点数、输出数和错误类别。
每次召回至少记录:
- mode、请求文件数、候选数、选中数、返回数;
- hit/partial/miss、gap 类型和历史版本过滤数量;
- selector model、timeout、attempts、耗时和失败原因;
- 注入文本字符数与绑定出的 session resource 数;
- resolved Omni config hash。
Telemetry 默认不记录原始媒体、transcript/OCR 正文、真实路径或完整 Tool 参数值;需要
复现时使用 hash 和本地结构化执行记录关联。
## 17. 验收标准
### 17.1 收集边界
- 用户、普通 Tool、URL 和 inline 媒体识别成功后只通过 `FileRecognized` 入库;
- 固定与模型 policy 成功后只通过 `OmniPolicySucceeded` 入库;
- 不存在 `MediaTurnCompleted`、Agent write Tool 或 final-answer extraction 写路径;
- `llmContent`、`returnDisplay` 和普通文本中的路径不会被解析为 Memory 产物;
- `/learn` 不出现在实现接入点中。
### 17.2 文件与版本
- 同一显式 fileRef、相同完整 SHA-256 重复识别不会产生重复版本;
- 同一 fileRef 内容改变后创建新版本,默认召回不返回旧结果;
- 两个独立来源即使完整 SHA-256 相同,也保留两个 file/root graph;
- URL 到受管本地文件的本地化 lineage 保持同一个 fileId;
- 名称、大小、mtime、ETag 或部分 hash 相同不能合并版本。
### 17.3 Policy 原子性
- 多输出 policy 只有全部 required/returned artifacts 校验通过才一次性入图;
- 任一媒体识别或文本读取失败时,不出现部分衍生物或孤立执行节点;
- include、retain 和 source omit 不改变事实收集结果;
- PostToolUse hook artifact 不会被登记为 policy 自身输出;
- 相同 invocation 重放幂等,cache hit 不复制结果节点。
### 17.4 主动召回
- `active` 模式只暴露 Recall Tool,不自动运行 side query;
- Tool 只能查询当前 session resource 对应的文件内图;
- 可用衍生媒体返回新的或复用的 session resourceId,不返回真实路径;
- status、entries、gaps、coverage、evidence 和 provenance 字段完整;
- Agent 不能通过 Recall Tool 写入或修改 Memory。
### 17.5 被动召回
- `sideQuery` 模式不暴露主动 Recall Tool;
- 候选只来自当前请求显式引用文件的 current-version graph;
- selector 只能返回 manifest 中的 entryId,非法或跨 root ID 整体拒绝;
- selector 不接收原始媒体、大文本或本地路径;
- 结果在相关主请求前注入;timeout 时空召回且不阻断主请求;
- side query 不创建任何新 File、PolicyResult 或 Agent conclusion。
### 17.6 配置与隔离
- `omni.memory` 没有 enabled 总开关;
- mode、预算、数组替换和 workspace trust 合并行为可验证;
- 不同 project/workspace 的 Memory 不会互相召回;
- 相同输入、配置、Tool 版本和参数可以从记录完整复现收集与召回条件。
## 18. 实现阶段仍需确定的细节
以下是实现方案选择,不改变本文已经确定的需求边界:
1. `File`、`FileVersion`、`PolicyExecution` 和 `PolicyResult` 的精确持久 schema 与
schema migration 方式;
2. `NormalizedPolicyOutput.role` 的初版完整枚举及自定义 role 命名规则;
3. store 后端、索引布局、事务和崩溃恢复实现;
4. side-query candidate 的确定性预排序、description 生成和 token 预算;
5. 历史版本、孤立受管文件、quarantine 与 cache 的 retention/GC;
6. cache hit 是否单独保存轻量 execution 记录,以及 provenance 的精确引用方式。
这些选择不得改变已经确认的约束:**只有 FileRecognized 与
OmniPolicySucceeded 两个收集触发点、Harness 独占写入、文件内图、完整 SHA-256
版本判断、policy 原子提交、active/sideQuery 共用只读协议,以及默认只召回当前
版本**。

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,281 @@
# Omni 受管媒体存储设计
## 状态
- 状态:Draft
- 范围:Qwen Code 实验分支中 Omni 多模态资源与中间产物的物理存储管理
- 关联设计:
[多模态文件识别与元数据提取架构设计](./2026-07-29-multimodal-file-recognition-and-metadata.md)、
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)、
[Omni 多模态 Memory 架构设计](./2026-07-29-omni-multimodal-memory.md)
## 1. 背景
前三篇设计反复引用"受管本地文件 / Omni 管理目录 / managed storage",但没有任何
一篇定义它的物理形态。它至少要同时承载四类文件:
1. URL 下载与 inline 解码的本地化结果(识别设计 §6.1、§7);
2. Policy Tool 的衍生产物:音轨、切片、关键帧、降采样图、转写文本(Policy 设计
§6.2);
3. Policy 执行失败后被隔离的 quarantined 产物(Policy 设计 §9);
4. 被 Memory active 记录长期引用、需要跨 session 存活的衍生物(Memory 设计
§13:_artifact 生命周期不得早于仍引用它的 active Memory 记录_)。
第 4 条决定了这不能是一个"会话结束即清理的临时目录":Memory 是跨 session 的,
它引用的媒体文件也必须跨 session 存活。本设计定义目录布局、写入协议、生命周期
与垃圾回收,作为其余三篇的公共物理底座。
## 2. 目标与非目标
### 2.1 目标
- 为四类文件定义统一的、按 project 隔离的目录布局;
- 用内容寻址(SHA-256)保证对象不可变、天然去重,与 Memory 设计 11.3 的
content-hash 复用键对齐;
- 定义 staging → 提交 / 隔离的原子写入协议,与 `OmniPolicySucceeded` 的事务
边界一致;
- 定义崩溃恢复:启动时清理半成品,不留下 Memory 可见的孤儿引用;
- 定义 GC:以 Memory active 记录为根的 mark-and-sweep,加保留期与容量预算;
- 配置进入 `omni.storage`,纳入 resolved Omni config hash。
### 2.2 非目标
- 不设计 Memory 图数据本身的存储后端(Memory 设计 §13 单独决定);
- 不改变现有 session artifact store 的职责(它仍服务 UI metadata);
- 不提供跨 project 共享、远端同步或多机存储;
- 不做加密存储;实验分支按本地开发机信任模型处理。
## 3. 位置与隔离
存储根目录固定为 project 作用域:
```text
<projectRoot>/.qwen/omni/
```
- 与 Memory 设计 §13 的"以 project/workspace 为隔离边界"一致:不同 project 的
对象、staging 与 quarantine 互不可见,GC 也只在本 project 内运行;
- 目录创建时写入自忽略文件(`.qwen/omni/.gitignore` 内容为 `*`),保证媒体
对象不会进入版本控制;
- Omni 写入链路只在 trusted workspace 激活;未信任 workspace 中不创建该目录,
也不执行任何 fixed policy(与 Policy 设计 §12.1 的 trust 合并语义一致);
- 目录权限 `0700`;内部只允许普通文件与目录,发现 symlink 一律拒绝并告警。
## 4. 目录布局
```text
.qwen/omni/
├── .gitignore # 内容为 *,自忽略
├── objects/ # 内容寻址对象库(不可变,已提交内容的唯一存放地)
│ └── sha256/
│ └── ab/
│ └── ab3f…e9.mp4 # <sha256 前 2 位>/<完整 sha256>.<按检测 MIME 的扩展名>
├── downloads/ # URL 本地化进行中的 .part 文件
│ └── <downloadId>.part
├── staging/ # policy invocation 的工作目录(提交前)
│ └── <invocationId>/
│ ├── out-audio.wav
│ └── out-keyframe-01.jpg
└── quarantine/ # 失败 invocation 被隔离的产物(可调试、有界)
└── <invocationId>/
```
### 4.1 objects:内容寻址对象库
- 对象以完整内容 SHA-256 命名,写入后不可变;扩展名取自统一识别的
`detectedMimeType`,便于 ffmpeg/ffprobe 与人工调试直接识别;
- 同一份字节在任何来源出现(两个 File 内容相同、policy 结果 cache hit)都只
存一份物理对象——这正是 Memory 设计 11.2/11.3"跨 File 复用底层 artifact、
不合并图节点"的物理实现:File/lineage/provenance 属于 Memory 图,字节属于
对象库;
- `ToolArtifact.managedId` 与 `artifactRef.managedId` 即对象键
(`sha256:<hash>`),对模型与协议层是 opaque ID,真实路径不外泄;
- 对象写入协议:先写同目录 `.tmp` 文件,流式计算 hash,完成后原子 rename 到
最终 hash 路径;目标已存在(去重命中)时直接丢弃 `.tmp`。
### 4.2 downloads:URL 本地化
- 识别设计 §7 的 `.part` 文件统一放在此处,`downloadId` 为随机标识;
- 下载完成、重新 sniff/probe/hash 之后按 4.1 协议晋升到 `objects/`,`.part`
删除;
- 大小许可(100 MiB 门槛)中断的 `.part` 保留以支持续传,但受 §6 的启动清理
与保留期约束。
### 4.3 staging:policy 工作目录
- FixedPolicyOrchestrator / Scheduler 在每次 MediaPolicyTool invocation 前创建
`staging/<invocationId>/`,并把它作为 Tool 唯一允许的输出目录注入;
- Tool 写完产物返回后,产物在 staging 内完成识别、hash 与 descriptor 校验
(Memory 设计 §6.4 的 staging 语义);
- `OmniPolicySucceeded` 提交顺序固定为:**先把全部产物晋升到 `objects/`,再
提交 Memory 事务**。这样 active Memory 记录引用的对象必然已存在;反向顺序
可能在崩溃时留下"Memory 指向不存在文件"的坏引用。晋升成功但 Memory 提交
失败时留下的是无引用孤儿对象,由 GC 回收,无正确性风险;
- 提交或隔离后删除该 invocation 的 staging 目录。
### 4.4 quarantine:失败隔离
- invocation 失败(Tool error、产物校验失败、部分 required 缺失)时,整个
staging 目录移动到 `quarantine/<invocationId>/`,保留原始文件名与一份
`reason.json`(失败原因、tool、policyId、时间)供实验调试;
- quarantine 永不被识别、不进入 Memory、不能被召回或重新绑定为 session
resource(Memory 设计 §15);
- 受独立的保留期与容量预算约束(§7 配置),超限时按最旧优先删除。
## 5. 写入者与职责边界
| 写入者 | 允许写入的区域 | 说明 |
| ------------------- | -------------------------------------------------------- | ------------------------------- |
| 识别服务(本地化) | `downloads/`、`objects/` | URL 下载、inline 解码落盘与晋升 |
| Policy Tool | 仅注入的 `staging/<invocationId>/` | 不允许写 objects 或 workspace |
| Orchestrator/bridge | `objects/`、`quarantine/` | 晋升与隔离,唯一的状态迁移者 |
| Memory / Recall | 只读 `objects/` | 通过 managedId 解析,不写入 |
| GC | 删除 `objects/`、`downloads/`、`staging/`、`quarantine/` | 见 §6 |
用户原始输入的本地文件**不复制进对象库**:它们留在原位置,Memory 以
`localPath + sha256` 记录身份(识别设计 §6.5)。只有"Qwen Code 自己生成或
下载的字节"才进入 `objects/`。这避免大视频在磁盘上双份存放;代价是用户文件
被移动/删除后 Recall 返回 `artifact_unavailable` gap,这是 Memory 设计 §12 已
定义的正常路径。
## 6. 生命周期与垃圾回收
### 6.1 启动恢复(已实现,`omni/recovery.ts`)
每次启动(或首次触碰 Omni 链路时)执行一次恢复扫描。实现相对初稿的修正——
多进程共存迫使"删除所有 staging"退化为宽限期语义:
1. 删除 `staging/` 下**超过宽限期(1h)**的目录——宽限期必须长于策略工具
最长超时(配置校验强制 `runtime.timeoutMs` 低于宽限期),否则第二个 CLI
进程的恢复扫描会把另一个进程正在转码的工作目录删掉。晋升半成品 `.tmp`
同理(1h 宽限);
2. 删除超过保留窗口(48h)的 `downloads/*.part`;无续传逻辑,窗口纯为事后
检查中断下载留的调试期;
3. 按保留天数与容量预算清理 `quarantine/`(超预算最旧优先);
4. 抽样校验 `objects/` 对象名与实际 hash 一致(每次至多 3 个、单个 ≤64MB,
避免在首次投递前的内联扫描里 hash 多 GB 视频);发现损坏对象删除时**级联
清理降质缓存**(`policy-cache.json` 中以它为源或产物的条目),防止缓存
命中一个永远无法投递的对象。
### 6.2 mark-and-sweep GC(S6 落点)
- **根集合**:Memory store 中全部记录引用的 managedId——`entries[].artifactRef.managedId`
与 `versions[].source.locator`(`protocol: 'managed'`,tool/URL 来源媒体的
身份锚,见 Memory 设计 §11.2.1 实现注记)两处都算,含仅被 provenance 引用的
历史版本对象——加上当前进程 MediaResourceRegistry 正在使用的对象;
- **清扫对象**:不在根集合中、且自晋升起超过 `retentionDays` 的对象;保留期
兼作宽限期,保证"晋升成功但 Memory 提交失败"的孤儿和跨进程 race 不被立刻
误删;
- **触发时机**:启动恢复后异步执行;超过 `maxTotalBytes` 时提前触发,仍超限
则从最旧的无引用对象继续删除;**有引用对象永不删除**,即使超预算——此时
告警并置"停止新衍生物"标志,由 orchestrator 消费(等价于 Policy 设计 §8.4
的预算停止语义);
- **级联**:对象删除联动清理其 `upload-cache.json` 条目与降质缓存条目
(复用恢复扫描已实现的级联,见 §6.1 第 4 条);反向不成立;
- GC 与 Memory 的一致性:先从 Memory 快照确认无引用,再删除文件;不存在
"先删文件再改记录"的窗口。快照读取失败时 GC 整体跳过(fail-closed:
读不到根集合就不删任何东西)。
## 7. 配置
```jsonc
{
"omni": {
// 受管媒体存储配置;全部纳入 resolved Omni config hash。
"storage": {
// 无引用对象自晋升起的保留天数;到期后可被 GC 清扫。
"retentionDays": 14,
// objects 区域的总容量软预算;超限提前触发 GC 并告警。
"maxTotalBytes": 21474836480,
// 中断下载 .part 的续传保留窗口(小时),超期启动时清理。
"partRetentionHours": 48,
// 失败隔离区配置。
"quarantine": {
// 隔离产物保留天数,供实验调试。
"retentionDays": 7,
// 隔离区容量上限,超限按最旧优先删除。
"maxBytes": 5368709120,
},
},
},
}
```
校验:所有数值必须为正;`maxTotalBytes` 不得小于 transport guard 单媒体上限的
10 倍(防止配置出一个装不下正常实验产物的库)。scope 合并沿用 Omni 统一语义。
## 8. 上传缓存
投递层统一采用 DashScope 官方临时上传(Policy 设计 §10.3),本存储为其维护
上传缓存——对象字节到 48h 有效 oss:// URL 的映射:
```text
uploads: (sha256, model) → {
ossUrl: "oss://dashscope-instant/…",
uploadedAt: string,
expiresAt: string, // uploadedAt + urlTtlHours(默认 47h,对官方 48h 留余量)
}
```
规则:
- 缓存 key 包含 model:官方文档声明文件与模型绑定(实测更宽松,但按文档保守
处理),换模型触发重传;
- 缓存 miss 或过期时,upload service 从 `objects/` 读取字节重传,写回新条目;
- **oss URL 只存在于上传缓存**,Memory、执行记录与任何持久协议都只引用
sha256——URL 是投递缓存,不是身份;
- 对象被 GC 删除时,联动删除其全部 uploads 条目;反向不成立(缓存过期不影响
对象);
- 缓存的持久化形态与 Memory store 同级决定(同一后端或独立轻量表均可),但
必须在进程重启后存活——否则每次重启都重传全部媒体;
- 上传缓存不参与对象引用计数:uploads 条目不能阻止 GC。
## 9. 失败语义
| 场景 | 行为 |
| ---------------------------- | ------------------------------------------------------------------------ |
| 对象晋升时目标 hash 已存在 | 去重命中,丢弃临时文件,复用现有对象 |
| 晋升成功、Memory 提交失败 | 孤儿对象留在 objects,GC 宽限期后回收 |
| 晋升中途崩溃 | 残留 `.tmp` 启动时清理;不存在半个可见对象 |
| staging 目录在提交前崩溃残留 | 启动时整目录删除,Memory 无引用 |
| 对象文件被外部删除/损坏 | Recall 返回 `artifact_unavailable` gap;抽样校验发现损坏时同样降级并告警 |
| quarantine 超预算 | 最旧优先删除,只影响调试可见性 |
| objects 超预算且全部有引用 | 告警并拒绝新衍生物产生,不删除有引用对象 |
| 上传缓存条目指向已 GC 对象 | 条目联动删除;投递时按 miss 处理 |
| 缓存未过期但服务端已失效 | 投递收到 Provider 错误后使该条目失效并重传一次;仍失败则 fail closed |
| 发现 symlink 或非普通文件 | 拒绝该路径并告警,不跟随 |
## 10. 与现有机制的关系
- **session artifact store**(daemon-session-artifacts):继续承担 UI 展示与
session 级 metadata,其 session-scoped ID 与本存储无关;Omni artifact 的
UI 展示仍可经现有 `artifacts` 通道,但物理文件归本存储管理;
- **`ToolArtifact.storage: 'managed'`**:Omni 场景下 `managedId` 语义固定为
本存储的对象键;
- **workspace 文件**:Policy Tool 不写 workspace;`storage: 'workspace'` 的
artifact 仅用于用户显式要求落到工作区的场景,不受本存储 GC 管理;
- **Memory store**:引用关系的唯一权威;本存储不维护独立引用计数或 manifest,
避免双账本漂移。
## 11. 验收标准
- 同一字节内容从两个来源进入,`objects/` 只存一份,两个 File 的 Memory 记录
各自完整;
- policy 多产物提交是"全部晋升 + 一次 Memory 事务",杀进程后重启不存在
Memory 指向缺失文件的引用;
- 失败 invocation 的产物完整出现在 `quarantine/` 且带 reason.json,不可被召回;
- 启动恢复清理 staging/.tmp/过期 .part,不触碰有引用对象;
- GC 只清扫无引用且超保留期的对象;把 Memory 记录删除后对应对象在保留期后
被回收;
- 超容量预算时告警并停止新衍生物,有引用对象仍在;
- `.qwen/omni/` 不进入 git status;未信任 workspace 不创建该目录;
- 模型与协议层可见的只有 `sha256:<hash>` 形式的 managedId,任何返回中不出现
真实路径。
## 12. 实现阶段确认的细节
- 抽样完整性校验的比例与频率;
- `downloads/` 续传元数据(ETag/offset)的存放形式;
- GC 与多进程并发(同一 project 开多个 Qwen Code 实例)的锁策略——初版可用
目录锁 + "GC 只在持锁进程执行";
- 对象库是否需要按媒体类型分区统计(实验报表用途)。

View file

@ -1,5 +1,7 @@
# Web Shell channel sessions in the sidebar
[English](2026-08-03-web-shell-channel-session-sidebar.md) | [简体中文](2026-08-03-web-shell-channel-session-sidebar.zh-CN.md)
## Motivation
Daemon-managed channels create ordinary workspace sessions with
@ -12,7 +14,11 @@ stored in the selected workspace.
Add a two-option source switch above the sidebar's project session list:
- **Tasks** lists `sourceType: "default"` and remains the initial selection.
- **Tasks** requests `sourceType: "default"` and remains the initial selection.
The daemon catalog includes default, legacy, and `qwen-live` sessions.
Scheduled-task run sessions (`sourceType: 'default'` with a
`scheduled_task_run:` sourceId) appear in dedicated sections. Bound controller
sessions use `sourceType: 'scheduled_task'` and are excluded from this catalog.
- **Channels** lists `sourceType: "channel"`.
The switch is shown only when the daemon advertises
@ -46,8 +52,18 @@ visible text.
## Boundaries
- Channel configuration and runtime management are unchanged.
- Session source metadata and daemon list APIs are unchanged.
- Session Overview and Split View keep their existing default-session scope.
- Persisted session source metadata is unchanged. The public daemon `default`
filter now also matches `qwen-live`; other source filters stay exact, and an
explicit `sourceId` remains an exact restriction within the selected catalog.
The internal Conversations filter is unchanged.
- Session Overview, the Split View picker, and workspace total, running, and
attention counts share the expanded default catalog, including `qwen-live`.
Scheduled-task eligibility is unchanged. Web Shell hides delete and archive
actions for `qwen-live` sidebar rows and excludes them from the deletion picker.
Session Overview disables single and batch delete/archive for these tasks.
Opening and explicitly releasing these tasks remain available. Source metadata
is attribution, not daemon authorization: REST and ACP mutations keep their
existing built-in Live call protection and can terminate external Live tasks.
- The switch is in-memory UI state and resets to Tasks on page reload.
- Channel type classification reflects the current workspace configuration;
sessions do not persist a historical platform type.

View file

@ -0,0 +1,65 @@
# Web Shell 侧边栏中的渠道会话
[English](2026-08-03-web-shell-channel-session-sidebar.md) | [简体中文](2026-08-03-web-shell-channel-session-sidebar.zh-CN.md)
## 动机
daemon 管理的渠道会创建 `sourceType: "channel"` 的普通工作空间会话,但 Web Shell
侧边栏有意只请求 `default` 会话目录。因此,即使钉钉、飞书或其他渠道发起的会话
已经存储在选中的工作空间中,也无法从侧边栏打开。
## 设计
在侧边栏的项目会话列表上方增加包含两个选项的来源切换控件:
- **Tasks** 请求 `sourceType: "default"`,并保持为初始选项。daemon 目录包含
default、旧版无来源会话和 `qwen-live` 会话。定时任务运行会话(`sourceType: 'default'`
且 `sourceId` 以 `scheduled_task_run:` 开头)放入专用分组。绑定的控制会话使用
`sourceType: 'scheduled_task'`,不属于这个目录。
- **Channels** 列出 `sourceType: "channel"` 的会话。
只有 daemon 声明支持 `session_source_metadata` 时才显示切换控件。较旧的 daemon
继续使用现有的未过滤请求,不展示其无法支持的控件。
所选来源一致应用于活动、置顶、归档和次要工作空间的会话请求。复用现有会话行、
工作空间区块、分组、搜索、轮询和打开会话操作。渠道会话可以由外部消息创建,
而不触发 Web Shell 变更事件,因此展开的 Channels 列表使用活动会话轮询间隔,
而非 30 秒的空闲间隔。
当所选工作空间还声明支持 `channel_management` 时,Channels 目录使用每个会话
不可变的渠道实例名称(`sourceId`)关联当前渠道配置,并按 `config.type` 分组。
类型目录提供平台标签,使同一平台的多个实例共用一个可折叠区块。实例已不存在的
会话仍显示在 Other channels 下。如果目录不可用,列表保留现有回退行为,不隐藏
会话。在 Channels 视图中,渠道类型分组优先于用户自定义会话分组;Tasks 保留现有
组织行为。次要工作空间解析其自身工作空间范围内的渠道目录。
渠道适配器仍会在消息前附加面向模型的指令和上下文历史。daemon 提示请求通过单独的
转录显示元数据携带用户撰写的文本,因此实时和回放的 Web Shell 消息均不会暴露这些
隐藏上下文,渠道会话标题也由相同的可见文本生成。
## 边界
- 渠道配置和运行时管理不变。
- 持久化的会话来源元数据不变。daemon 的公共 `default` 过滤器现在也匹配
`qwen-live`;其他来源过滤器仍精确匹配,显式指定的 `sourceId` 仍在所选目录内
进行精确限制。内部 Conversations 过滤器不变。
- Session Overview、Split View 选择器,以及工作空间的会话总数、运行数和待处理数
共用扩展后的默认目录,其中包含 `qwen-live`。定时任务资格判断不变。Web Shell 对
`qwen-live` 侧栏行隐藏删除和归档操作,并将其排除在删除选择器之外。Session Overview 也禁用
这些任务的单行和批量删除、归档。仍可打开任务
或显式释放其运行时。来源元数据只用于归属,不是 daemon 授权依据;REST 和 ACP
变更保留内置 Live 通话保护,可以终止外部 Live 任务。
- 切换选项保存在内存中的 UI 状态里,页面重新加载后重置为 Tasks。
- 渠道类型分类反映工作空间当前配置;会话不持久化历史平台类型。
## 验证
- 断言来源切换控件受 `session_source_metadata` 能力控制。
- 断言初始选中 Tasks,且请求 `sourceType: "default"`。
- 断言选择 Channels 后,主工作空间列表和工作空间限定列表均请求
`sourceType: "channel"`。
- 断言 Channels 列表按活动会话间隔轮询。
- 断言同一平台的多个实例共用一个可折叠类型区块,其他平台会话仍分开展示,置顶会话
保留在所属类型区块内,无法匹配的会话保留在 Other channels 下。
- 断言渠道提示请求保留完整模型上下文,同时只记录用户撰写的文本用于转录显示。
- 运行侧边栏和工作空间区块单元测试、Web Shell 构建以及 TypeScript 类型检查。

View file

@ -0,0 +1,178 @@
# Omni 策略工具扩展与 memory 条件命名空间
## 状态
- 状态:Implemented(omni-experiment 分支)
- 范围:Qwen Code 实验分支 omni harness 的策略工具集与条件编排能力
- 前置设计:
[Omni 多模态数据处理 Policy 编排架构设计](./2026-07-29-omni-multimodal-policy-orchestration.md)、
[Omni 多模态 Memory 架构设计](./2026-07-29-omni-multimodal-memory.md)
- 需求来源:`omni-harness-gap-analysis.md`(2026-08-17,对照 omni harness 设计文档
与 Qwen-MM-Plugins 插件集的差距分析)
- 基线:`omni-experiment`,S1–S6 已合入(`e2e6541f01`)
## 1. 背景
差距分析对照 omni harness 设计文档(11 个工具)与 Qwen-MM-Plugins 插件集,
确认 6 个设计文档工具已实现、6 个未实现,另有编排层与计费杠杆两类差距。
本文档对应差距分析的优先级 1–5(❶–❼ + ❿⓫),即:
| # | 差距 | 本文对应 |
| --- | --------------------------------------------------- | -------- |
| ❶ | `omni_clip_image` 策略形态图片裁剪 | §3.1 |
| ❷ | `omni_caption_image` VL 图片描述 | §3.2 |
| ❸ | `omni_ocr_image` 图片 OCR | §3.2 |
| ❹ | `omni_clip_audio` 音频时间裁剪 | §3.1 |
| ❺ | `omni_caption_audio` 音频语义描述 | §3.2 |
| ❻ | `omni_understand_video_segments` 长视频分段并行理解 | §3.3 |
| ❼ | `memory.*` 条件命名空间 | §4 |
| ❿ | patch-grid 对齐 smart_resize + token 预算档位 | §5 |
| ⓫ | 动态 FPS 均匀采样 + 并行 seek 抽帧 | §5 |
优先级 6–7(grounding/segmentation、非 PDF 文档 visualize、video-memory 内容图、
视频/语音生成、image_search、OSS 远程读取)按差距分析"视产品定位排期"不纳入
本次范围。
## 2. 设计决策
### D1 模型调用类工具共用一个请求辅助模块
`omni_caption_image` / `omni_ocr_image` / `omni_caption_audio` /
`omni_understand_video_segments` 四个工具的远端调用同构:OpenAI 兼容
chat.completions、媒体 content part(image_url / video_url / input_audio
data URI)+ 指令文本、SSE 流式组装。该公共面收敛为
`omni/policy/tools/omni-chat-request.ts` 的 `requestOmniChatCompletion`,
复用 transcribe-audio 的 `parseSseTranscript` SSE 解析;非 2xx 一律只回报
`HTTP <status>`,上游 body 永不进入模型可见内容(与 transcribe-audio 一致)。
`omni_transcribe_audio` 自身的请求 helper 保留不动:它是纯音频 + 分片语义,
迁到共享模块只会增加耦合。
### D2 文本产物沿用转写协议,memory role 用预留枚举
四个文本产出工具的产物结构与 §6.2 转写协议一致:严格 UTF-8 text/plain 文件
产物 + `metadata.omniRole` 标注 + 强制披露。role 取值直接使用 memory 类型
枚举中已预留的 `'caption'` / `'ocr'` / `'summary'`
(`services/media-memory/types.ts` 的 `KnownMediaMemoryRole`),无需改动
memory 的 channel/coverage 推导:`ocr → onscreen_text`、`caption/summary`
按源模态落 `visual`/`acoustic`,coverage 默认为 `complete`。
### D3 裁剪工具的"反 no-op"与"角色标注"
`omni_clip_image` / `omni_clip_audio` 与 `omni_clip_video` 保持同一语义族:
- 时间/空间裁剪是**切取而非降质**:clip_audio 采样率/声道不动,clip_image
输出 PNG(幸存像素不再被有损重编码二次伤害);
- 全覆盖 no-op(整图/全时长"裁剪")在参数层或执行早期拒绝——否则只是
白跑一次有损重编码,披露还会谎称丢弃了范围外内容;
- 产物标 `omniRole: 'clip'`,memory 据此映射 `partial` coverage。
`omni_clip_image` 与模型直调的 `zoom_image` 是互补而非重复:zoom_image 用
归一化坐标、不走 policy 管线;clip_image 用像素坐标、可被 fixedPolicies
编排、产物入 memory 谱系。
### D4 长视频理解的分段规格
`omni_understand_video_segments` 按设计文档 §3.3 锁定两个上限:
`maxParallelSegments` ≤ 8(并发上限)、`maxSegmentBytes` 默认 10MiB(inline
上限)。分段重编码目标(360p / 450kbps 视频 + 32kbps 单声道 AAC)使 60s
上限分段约 3.6MB,远低于 inline 天花板;仍超限的分段内联标注失败而不是
发出必然失败的请求。时长不可探测时 fail-closed(无法规划分段);容器声称
时长超 512 段上限时同样 fail-closed(与 transcribe-audio 同一理由:
时长是攻击者可影响的元数据)。
### D5 memory.\* 命名空间的语义与求值时机
条件 DSL 新增第四命名空间 `memory.*`,六个 0/1 字段:
`hasTranscript` / `hasOcr` / `hasCaption` / `hasSummary` / `hasKeyframes` /
`hasClip`,语义为"该 item 版本的 memory 子图中已存在对应 role 的产物"。
关键设计点:
- **子图而非同版本**:`MediaMemoryService.collectVersionOutputRoles` 沿
DERIVED_FROM 子图(root 有界,M §8)收集 role。§4.1 链条"视频 → 抽音轨 →
转写"中,转写产物挂在派生音频版本下;只查本版本永远看不到它。
- **三值语义不破**:memory 未开启、item 无绑定、存储不可读时整个命名空间
缺失,`memory.*` 条件求值为 `unavailable`(由 `onConditionUnavailable`
决定 skip/run),绝不静默当假——否则"无转写才转写"在记忆不可读时会
退化成"永远转写"。
- **惰性求值**:orchestrator 仅在某条 policy 的 `when` 引用 `memory.*`
时(新增的 `conditionUsesNamespace` 判定)才做一次存储读取;不引用
memory 的既有配置零开销。
- **与执行级复用的分工**:`findReusableOutputs`(同文件+同指纹跳过重复
执行)管"幂等",`memory.*` 管"按记忆状态走分支"。两者可叠加:条件
no_match 时连复用记账都不发生。
### D6 patch-grid 对齐与 token 预算档位
新模块 `omni/smart-resize.ts`(移植插件 `shared/image.py` 的
`smart_resize` / `budget_to_pixels`):
- 网格因子 28(Qwen-VL 族:14px patch × 2×2 merge),一个视觉 token 覆盖
28² = 784 像素;与 `estimation.ts` raw-resource-v1 的 2048 px/token
保守估计刻意不同——估计器宁多算保安全,预算器对准计量网格。
- 档位:图片 small/normal/large = 256/1024/2048 tokens,视频逐帧
80/256/1024;最小档同时作为 minPixels(小图上采样到网格,避免不足一个
cell 的零碎计费)。
- 接入点:`omni_downsample_image` 新增 `tokenBudget` 参数(设置后覆盖
`maxDimension`,按显示尺寸——EXIF orientation ≥ 5 换轴——精确缩放到
网格);`omni_extract_keyframes` 新增 `frameTokenBudget`(逐帧网格尺寸)。
### D7 动态 FPS 均匀采样
`omni_extract_keyframes` 新增 `strategy: 'uniform'`(默认 `scene` 不变,
完全向后兼容):帧数 `clamp(窗口时长 × fps, 1, maxFrames)`,长视频自动
降采样率;每个时间戳独立 `-ss` 精确寻址、并行 4 路抽取(插件
`extract_frames_by_seeking` 形态);`startSec`/`endSec` 提供裁剪+采样一体。
时长未知时回退既有 scene 路径。这是"480p/10fps 降不了计费 token"实测结论
的直接对策:计量单位是 帧数×像素,fps 与逐帧像素预算才是有效杠杆。
## 3. 工具规格
### 3.1 裁剪
- `omni_clip_image`(image → PNG):`x`/`y`/`width`/`height` 像素坐标必填;
EXIF 方向先于裁剪烘焙,坐标即用户所见;动图双闸拒绝(与 downsample/convert
一致);role `clip`。
- `omni_clip_audio`(audio → WAV/MP3/M4A):`startMs` ≥ 0、`durationMs` ≥
1000(设计文档下限),默认到结尾;输入侧 `-ss`/`-t` + 重编码保证采样级
精确;role `clip`。
### 3.2 模型理解(文本产物)
- `omni_caption_image`(image → 文本,role `caption`):`prompt` 可配
(默认整体详细描述),`maxInputBytes` 默认 10MiB。
- `omni_ocr_image`(image → 文本,role `ocr`):`language` 可选提示
(默认自动检测),`prompt` 可覆盖默认 OCR 指令;解锁设计文档 §4.5 的
文档/文字图片 OCR fixedPolicy。
- `omni_caption_audio`(audio → 文本,role `caption`):与 transcribe-audio
同构(分片并发 3、共享时间预算、退化检测、单段失败内联标注),prompt
由参数/settings 提供,默认描述语音大意/音色/事件/情绪;与转写的分工是
"理解 vs 逐字"。
三者共有运维参数 `model` / `baseUrl` / `apiKeyEnv`,均列入
`operatorOnlyParams`(与 transcribe-audio 同一安全理由:端点与凭据选择
必须运维控制)。
### 3.3 长视频理解
`omni_understand_video_segments`(video → 文本,role `summary`):
`segmentSeconds` 默认 30(5–60)、`maxParallelSegments` 默认 8(上限 8)、
`maxSegmentBytes` 默认 10MiB;逐段 `[MM:SS-MM:SS]` 标注拼装,单段失败内联
标注,全段失败才整体报错。
## 4. 条件 DSL 扩展
`conditions.ts`:`MEMORY_CONDITION_FIELDS` + `memory` 上下文命名空间 +
`conditionUsesNamespace`;`orchestrator.ts` 在 pass 起始快照中按 item 求值
memory 命名空间;`service.ts` 新增 `collectVersionOutputRoles`(存储不可读
返回 undefined → `unavailable`)。
## 5. 验证
- 单元/集成测试:每个新工具独立测试文件(descriptor 声明、参数校验、
请求形态、披露文本、失败路径);`smart-resize` 网格/档位数学;conditions
的 memory 三值语义;service 的子图 role 收集(含 §4.1 派生链);
orchestrator 端到端:memory 门控 policy 首轮执行、次轮零调用。
- 全量回归:`src/omni` + `src/services/media-memory` 45 文件 1017 测试通过;
eslint 零告警;tsc 无新增错误。

View file

@ -76,13 +76,16 @@ Algorithm (every step that cannot attribute the tail with confidence returns wit
4. Load the transcript (`loadSession`); failure or `undefined` → return.
5. **Attribution evidence** (five checks, each closing a concrete wrong-terminal class):
- **Dispatch marker**: when the target's `in_flight` record carries `tailUuid`, the projection must contain that record AND at least one visible write (non-`system`, with a `message`) after it. The transcript is append-only, so anything after the marker postdates admission — an identity/ordering check immune to clock skew. A marker absent from the projection, or present with no visible write beyond it, fails closed. Records without a marker (legacy, or capture failure) fall through to the temporal chain below.
- **Projection-consistent temporal evidence**: the last transcript write that actually enters the api history the verdict runs on (non-`system` records with a `message`, mirroring `SessionApiHistoryAccumulator`) must be strictly after `target`'s `in_flight` `at`. Measuring the raw stream instead would let evidence the verdict never sees — a post-admission `ui_telemetry`/`custom_title`/… record — pass the check for a prompt that never reached the model. An empty message list (or system-only tail) fails the same check. Equality is vetoed too: both clocks are 1 ms-granularity `Date.now()` reads, so a write landing in the admission millisecond cannot be attributed.
- **Projection-consistent temporal evidence**: the last transcript write that actually enters the api history the verdict runs on (non-`system` records with a `message`, mirroring `SessionApiHistoryAccumulator`) must be strictly after `target`'s `in_flight` `at`. "Visible non-`system` write" means the SAME filtered set here and in step 6's provenance-bound completion guard: both read one attribution loop, whose single `provenance === 'system'` skip excludes system-injected notification/cron records that do enter the projection. Measuring the raw stream instead would let evidence the verdict never sees — a post-admission `ui_telemetry`/`custom_title`/… record — pass the check for a prompt that never reached the model. An empty message list (or system-only tail) fails the same check. Equality is vetoed too: both clocks are 1 ms-granularity `Date.now()` reads, so a write landing in the admission millisecond cannot be attributed.
- **Compression fence**: a `chat_compression` record carrying a `compressedHistory` written at or after `target`'s admission → return. The accumulator swaps the whole history for the compressed snapshot, so the verdict's projection no longer carries `target`'s turn and nothing may be attributed. Marker-bearing admissions detect "after admission" by position (any compression record past the marker) so a backward clock step cannot hide the reset; marker-less admissions compare wall clocks.
- **FIFO evidence**: the visible tail must be strictly after every _other_ prompt's settled terminal `at` (same-millisecond equality is vetoed, for the same clock-collision reason as above). Under FIFO admission `target`'s turn can only start after every predecessor settled, so an older tail belongs to that predecessor's turn. This closes the queued-never-dispatched class (`[A if, B if(queued), A term]` — A's tail predates A's own terminal, so B gets nothing) and the stale-dangling class left by restore paths that skip reconciliation (a later prompt's completed tail predates its own terminal, so the stale prompt gets nothing).
- **Deadline fence**: if any other prompt's terminal carries `code: 'prompt_deadline_exceeded'` → return. The deadline path releases the FIFO while the wedged agent is explicitly allowed to keep streaming (DAEMON-003), so that terminal's timestamp does not fence its turn's writes: stale writes can postdate both the terminal and `target`'s admission and the temporal checks above cannot veto them. The veto is deliberately unconditional: the append-only ledger never expires records, so one deadline-exceeded prompt keeps the session fail-closed for every later dangling prompt (a missing terminal, never a wrong one). A recency bound cannot distinguish the stale case from the adjacent-overlap case without daemon-generation tracking, which the ledger does not carry.
6. Build the api history (`buildApiHistoryFromConversation`) and classify the last `TURN_INTERRUPTION_HISTORY_TAIL_COUNT` entries with `detectTurnInterruption`, then apply the **id-less tool-call guard**: when the verdict is `none` but the api-history tail's last entry is a model turn holding any `functionCall` part (with or without an id), upgrade to interrupted — `detectTurnInterruption` ignores id-less functionCalls because they cannot be paired on the wire, but reconciliation needs no wire pairing; a model tail holding a tool call means the daemon died mid tool-run.
- `none` (clean tail) → append `{"terminal":"completed","stopReason":"reconstructed_from_transcript"}`.
6. Build the api history (`buildApiHistoryFromConversation`) and classify the last `TURN_INTERRUPTION_HISTORY_TAIL_COUNT` entries with `detectTurnInterruption`, then apply the **id-less tool-call guard**: when the verdict is `none` but the **classifiable** tail's last entry is a model turn holding any `functionCall` part (with or without an id), upgrade to interrupted — `detectTurnInterruption` ignores id-less functionCalls because they cannot be paired on the wire, but reconciliation needs no wire pairing; a model tail holding a tool call means the daemon died mid tool-run.
- The classifiable tail is `historyTail.slice(0, effectiveHistoryEnd(historyTail))`: the sliced api-history tail with trailing system-injected `<task-notification>` entries trimmed. The guard MUST read the same array the verdict read. Detection trims those notifications internally, so reading the raw tail lets a notification hide the model entry from this guard while the trim hides the notification from detection — both miss at once, and a prompt that died mid tool-run gets stamped `completed`. Note `effectiveHistoryEnd` indexes the sliced tail, not the full api history: this step trims `-TURN_INTERRUPTION_HISTORY_TAIL_COUNT` entries first, so any index channel from the projection has to be re-based onto that slice.
- **Provenance-bound completion guard**: a `none` verdict alone cannot certify completion. The classifier's trim is a TEXT-SHAPE rule and cannot tell a real prompt whose whole text happens to be an envelope — pasted out of a transcript, or forwarded verbatim by a channel/SDK client, and stamped `provenance: 'real_user'` so the temporal-evidence check above counts it as the target's own write — from a system-injected one. Trimming that entry exposes the PREVIOUS turn's model text as the tail, so the verdict reads `none`. Before appending `completed`, therefore, require the last visible record with `provenance !== 'system'` to be `assistant` or `tool_result`; otherwise append nothing. `tool_result` is accepted for the GOAL-BOUNDARY shape only, not for tool turns in general: `boundary >= end` in `detectTurnInterruption` needs the closing call id to be in `completedToolCallIds`, and that set has exactly two producers — the `goal_turn_end` branch and the compression payload, both in `packages/core/src/services/session-api-history.ts`. An ordinary closed tool pair never reaches this guard: with no boundary record its trailing `functionResponse` classifies as `interrupted_prompt` and takes the interrupted path below. One user-role tail DOES prove completion and is exempt: a **locally handled slash command** (`/help`, `/docs`, `/export md`, ...). Such a prompt never reaches the model — `Session.prompt()` records it as an ordinary user message, the command answers with a `system` / `slash_command` `phase: 'result'` record (`sentToModel !== true`, assistant-only `outputHistoryItems`), and `SessionApiHistoryAccumulator` pops the user entry back out of the projection, so the verdict reads `none` while the last visible non-`system` write is `type: 'user'`. Recognising that result record (matched against the popped user record, under the same post-admission rule as the compression fence) restores the pre-guard outcome; without it the promptId stays `unknown` on every later load, because the ledger is append-only and `settledPromptIds` filters it. The exemption is bound to the authoritative record, not to a text shape, so it cannot re-open the envelope case above.
- `none` (clean tail whose last non-`system` visible write is model-facing) → append `{"terminal":"completed","stopReason":"reconstructed_from_transcript"}`.
- `interrupted_prompt` / `interrupted_turn` / upgraded tool-call guard → append `{"terminal":"interrupted","code":"daemon_lost"}`.
- notification-only post-admission tail (no non-`system` visible write at all), or a `none` verdict whose last non-`system` visible write is user-role with no matching local slash-command result → append nothing (fail closed). This veto is silent: reconciliation runs in the serve daemon, which binds no debug-log session — `getActiveSession()` resolves to `null` there (no `runWithDebugLogSession` or `sessionIdContext` binding exists in `packages/cli/src/serve` or `packages/acp-bridge`, and the Configs that set the process-wide fallback are constructed in the spawned agent child, a different process) — so the absent ledger record is the only observable consequence.
- transcript unreadable or history undefined → append nothing (fail closed).
7. **TOCTOU fence**: re-read the ledger immediately before the append; any change since the step-1 snapshot (the ledger is append-only, so an unchanged record count proves it) → return. A prompt admitted while `loadSession` ran appends its `in_flight` after the snapshot, and the visible tail the verdict was computed from may now belong to it — the verdict must not be stamped onto the old dangling id.
8. Append best-effort; an append failure leaves the prompt `unknown`.
@ -145,7 +148,7 @@ Records contain only `v`, `promptId`, `state`/`terminal`, `code`, `stopReason`,
- Unit-test the ledger module: append/read round-trip, torn-tail tolerance, torn-tail sealing (a sealed fragment cannot fuse with the next appended record), dangling reduction, recent-terminal windowing.
- Unit-test bridge write points with the existing FakeAgent harness: in_flight on admission, terminal on completion, flush on `daemon_shutdown`, in_flight recorded for queued admissions and both prompts flushed on shutdown, best-effort failure containment.
- Unit-test reconciliation branches with a real `SessionService` fixture: clean tail → completed, `interrupted_prompt`/`interrupted_turn` → interrupted, id-less functionCall tail → interrupted, missing transcript → fail closed, no dangling → no-op, multiple dangling → fail closed (nothing appended), settled-then-queued interleave (`[A if, B if, B cancelled]`) → A attributed, valid interleave (`[if p1, if p2, term p1]` on a real time axis) → p2 attributed, stale tail (last transcript write predates the admission) → fail closed, queued-never-dispatched (`[A if, B if, A term]` with A's terminal postdating its turn) → fail closed, stale dangling behind a later settled prompt → fail closed, post-admission compression checkpoint → fail closed, post-admission system-record-only tail → fail closed, idempotence.
- Unit-test reconciliation branches with a real `SessionService` fixture: clean tail → completed, `interrupted_prompt`/`interrupted_turn` → interrupted, id-less functionCall tail → interrupted, missing transcript → fail closed, no dangling → no-op, multiple dangling → fail closed (nothing appended), settled-then-queued interleave (`[A if, B if, B cancelled]`) → A attributed, valid interleave (`[if p1, if p2, term p1]` on a real time axis) → p2 attributed, stale tail (last transcript write predates the admission) → fail closed, queued-never-dispatched (`[A if, B if, A term]` with A's terminal postdating its turn) → fail closed, stale dangling behind a later settled prompt → fail closed, post-admission compression checkpoint → fail closed, post-admission system-record-only tail → fail closed, id-less functionCall tail followed by a notification → interrupted (both guards read the same trimmed tail), id-less functionCall tail followed by a FULL WINDOW of notifications → interrupted (the notification trim must run before the bounded tail window is cut, or the window holds nothing but notifications, the classifiable tail is empty, and the transcript-wide attribution loop still certifies `completed`), real-user tail shaped like a notification → fail closed (provenance-bound completion guard), notification postdating an EARLIER turn's dangling id-less functionCall → fail closed (the ATTRIBUTION direction of the `provenance === 'system'` skip: counting the notification appends a wrong `interrupted / daemon_lost` for a prompt that never dispatched — the notification-only case above does not witness this, because the completion guard vetoes that shape even with the skip deleted), assistant tail followed by a notification → completed (the skip's other direction: a notification must not become the last visible non-`system` write), locally handled slash command (user record popped by the accumulator plus its `slash_command` result record) → completed, idempotence.
- Unit-test the sidecar lifecycle in `sessionService`: archive/unarchive move the ledger, move failure is warn-only, destination-exists merges instead of wedging, session removal deletes both states, insight/usage scans skip `.ledger.jsonl`.
- Route-level test through `POST /session/:id/load`: field presence, omission without ledger, attached loads skip reconciliation, active prompts skip reconciliation, resume responses stay free of `promptTerminals`.
- Final verification on root `npm run build` and `npm run typecheck`.

View file

@ -2,7 +2,7 @@
## Problem
`/goal <objective>` accepts any non-empty string. The objective is judged by an independent verifier that only sees transcript evidence (`goalJudge.ts`, `goal-verifier.ts`): printed text cannot prove that tests passed or files changed, claims about user actions need a real user message, and an objective nobody can evidence keeps the loop running until a limit stops it. Nothing in the product tells users this. The only guidance was the web-shell placeholder `all tests pass and the lint is clean`, which itself has no check attached.
`/goal <objective>` accepts any non-empty string. The objective is judged by an independent verifier that only sees transcript evidence (`goal-verifier.ts`): printed text cannot prove that tests passed or files changed, claims about user actions need a real user message, and an objective nobody can evidence keeps the loop running until a limit stops it. Nothing in the product tells users this. The only guidance was the web-shell placeholder `all tests pass and the lint is clean`, which itself has no check attached.
## Survey

View file

@ -81,9 +81,12 @@ better, on the turn where they coincide.
`limitKind`. The bound stands down when `spentBudget` finds any spent
allowance, the streak stays on the record, and the gate runs as it would
have.
- A checkpoint stall streak. A model that fills the evidence window with prose
- A checkpoint stall streak. (Historical: since the verifier judges a proposal
from the transcript tail directly, `update_goal` no longer answers
`checkpointRequired` and no checkpoint runs; the stall streak stays at zero.)
A model that fills the evidence window with prose
and calls no tool is quiet by this bound's measure and overflowing by the
checkpoint's, and `update_goal` answers `checkpointRequired` on an
checkpoint's, and `update_goal` used to answer `checkpointRequired` on an
overflowing catalog without recording a proposal. That Goal is drowning in
evidence, not idling: the pause's remedy (resume) would send it straight
back into the same window, while the stall breaker stops it with the reason

View file

@ -0,0 +1,877 @@
# Linux kernel sandbox (bwrap + landlock-run) design
[English](2026-09-09-linux-kernel-sandbox.md) | [简体中文](2026-09-09-linux-kernel-sandbox.zh-CN.md)
Internal design document for giving qwen-code kernel-level isolation on Linux
hosts that have no container runtime — closing the gap where today
`--sandbox` on Linux means "docker/podman or nothing", and
`QWEN_SANDBOX=false` is the default of the entire integration-test matrix, so
most Linux users run the agent with no OS-level confinement at all.
Reference points verified against source in this repo and against the
published sources of three comparable agents (Codex CLI, Claude Code,
DeepSeek Harness) on 2026-09-09; see § Evidence.
## Phase 0 — Verified baseline before this PR
This section records the pre-implementation state inspected on 2026-09-09. P0 below describes the delivered bwrap changes; the baseline statements are not claims about the current branch. References to changed code use file names and symbols rather than obsolete line offsets.
| Fact | Evidence |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Sandbox is a whole-CLI re-exec ("hop"), never per-command | `packages/cli/src/llm.tsx` (`main`, sandbox hop) calls `loadSandboxConfig` → `start_sandbox` → `process.exit(0)` |
| Backends today: `docker`, `podman`, `sandbox-exec` only | `packages/cli/src/config/sandboxConfig.ts` (`VALID_SANDBOX_COMMANDS`) |
| On Linux, container backends are only candidates when sandbox was enabled explicitly | `sandboxConfig.ts` (`getSandboxCommand`) — `sandbox === true` gates the docker/podman push |
| No landlock/seccomp/bwrap/unshare isolation implementation exists in the repo | grep over `packages/**/*.ts` — only incidental matches |
| macOS Seatbelt: 6 builtin `.sb` profiles, `(allow default)` + deny-write whitelist posture | `packages/cli/src/serve/sandbox-macos-*.sb`; `sandbox.ts` (`BUILTIN_SEATBELT_PROFILES`) |
| The sandbox-exec branch of `start_sandbox` is the structural template for any in-place backend | `packages/cli/src/serve/sandbox.ts` (`start_sandbox`, Seatbelt) |
| `SANDBOX` env var marks "already inside the sandbox" and is consumed by UI/warnings/preconnect | `sandboxConfig.ts` (`getSandboxCommand`), `headlessSafetyWarnings.ts:43`, `systemInfo.ts:141` |
| `SandboxConfig.image` is currently required for every command | `packages/core/src/config/config.ts` (`SandboxConfig`), `sandboxConfig.ts` (`loadSandboxConfig`) |
| Update-relaunch handoff branches on `command !== 'sandbox-exec'` (container vs non-container) | `packages/cli/src/llm.tsx` (`main`, sandbox handoff) |
| Vendored platform binaries are committed to git and shipped via `files: [dist, vendor, ...]` | `packages/core/vendor/ripgrep/<arch>-<platform>/rg`, `git ls-files packages/core/vendor/` |
| Integration matrix default is `QWEN_SANDBOX=false` | root `package.json:57,61,64` (`test:integration:*:sandbox:none`) |
Comparator facts (verified against Codex CLI source, dsh source, and Claude
Code binary evidence; details in § Evidence):
- Codex: `seccompiler` + `landlock` crates + vendored bubblewrap; Seatbelt
`.sbpl` with `(deny default)`; two native Windows sandbox backends;
`codex sandbox` first-class subcommand; `SandboxPolicy` variants carry
`network_access`.
- dsh: bwrap preferred, fallback to a self-written 299-line C11
`landlock-run` (musl-static, self-restrict-then-exec, fail-closed,
`full`/`partial` enforcement reporting, launcher-failure exit 125
contract); distributed as platform optional npm packages.
- Claude Code: bwrap + seccomp on Linux without a container runtime;
`CLAUDE_CODE_FORCE_SANDBOX`; `--no-sandbox` itself blocked by its own
auto-mode rules.
## Goals / non-goals
**Goals**
1. Linux confinement that needs **no root, no daemon, no image, no compiler
on the consumer host** — usable on rootless CI, shared build machines,
and minimal containers.
2. Slot into the existing hop architecture (`loadSandboxConfig` →
`start_sandbox`) with the same writable-root semantics the macOS Seatbelt
path already exposes.
3. Fail-closed everywhere a sandbox was explicitly requested; honestly
report enforcement level (`full` / `partial` / `none`) everywhere it was
not.
4. Ship in reviewable increments: opt-in first, default flip last.
**Non-goals**
- No Starlark-style policy language (the existing tree-sitter AST + rule
layer is the command-decision plane; this design is the isolation plane).
- No built-in MITM network proxy (Codex's five-figure-line network-proxy).
The existing `QWEN_SANDBOX_PROXY_COMMAND` hook remains the proxied story.
- No Windows native sandbox (a separate, larger investment; dsh's
ACL/restricted-token design is the reference when that work starts).
- No per-command confinement in v1 (see D2).
- No change to macOS Seatbelt profiles or the container backends.
## Design decisions
### D1 — Two Linux backends: system `bwrap` preferred, vendored `landlock-run` fallback
**Decision.** Add two new sandbox commands:
- `bwrap` — uses the host's bubblewrap (in every mainstream distro's repos).
Probe is **functional**: run a minimal confined `true`, not `--version`
(an installed bwrap still fails where `kernel.unprivileged_userns_clone=0`
or an LSM denies `mount`).
- `landlock` — uses a vendored `qwen-landlock-run` helper: a ~300-line C11
program over the raw Landlock UAPI, statically linked with musl, committed
per-arch under `packages/core/vendor/landlock-run/`. Landlock is an
independent syscall family that needs neither user namespaces nor mount
privileges, so it works exactly where bwrap cannot.
**Why this shape.** Node cannot link seccomp/Landlock libraries the way
Codex's Rust does; the confinement must be applied by an executable we
spawn. dsh proved the fallback-helper pattern is cheap (299 lines, one
file, zero libraries beyond musl) and shippable without ever compiling on
the consumer host.
**Why bwrap first.** Zero new bytes to ship, and `--ro-bind / /` gives a
read-only host root with per-directory writable overrides in a single flag. It
can also unshare the PID and network namespaces; only the network one is used
here — D6 explains why the PID namespace is left alone.
### D2 — Hop granularity (match the Seatbelt branch), not per-command
**Decision.** The new backends wrap the whole CLI re-exec in
`start_sandbox`, exactly like `sandbox-exec` today. Per-command confinement
(wrapping each `run_shell_command` spawn, as Codex/dsh do) is explicitly
deferred.
**Rationale.** The hop requires touching only `sandboxConfig.ts`,
`sandbox.ts`, `config.ts` (type), `llm.tsx` (handoff branch), and
`systemInfo.ts`. Per-command confinement requires a policy carrier through
core's tool scheduler, per-call network decisions (the LLM client must stay
unconfined — the reason our Seatbelt `proxied` profile exists), and a
denial-escalation UX. It is a separate design; the hop gets ~90% of the
protection (agent-executed commands cannot escape the workspace) for ~10%
of the surgery.
**Accepted cost — denial visibility.** A hop confines the whole process, so a
refused write reaches the model as a bare `EROFS`/`EACCES` from
`run_shell_command` with nothing marking it as a _policy_ denial. dsh recorded
the failure mode that produces: "a denial with no escalation path is terminal —
the model can only give up, which pressure-cooks operators into configuring
`danger-full-access` globally and defeats the sandbox." A denial marker plus a
one-shot escalation is exactly what per-command confinement buys, so v1
mitigates rather than solves — and the mitigation is an edit to machinery that
already exists:
`core/src/core/prompts.ts` (`getCoreSystemPrompt`) already branches the system prompt on
`process.env['SANDBOX']`: `'sandbox-exec'` yields a "# macOS Seatbelt"
section, any other non-empty value yields a "# Sandbox" section, and unset
yields "# Outside of Sandbox". Two consequences:
- Without an edit, `SANDBOX=bwrap` falls into the generic branch and the model
is told it "is running in a sandbox **container**" — wrong, and it names
`Operation not permitted` while bwrap denials read `Read-only file system`.
- With a small added branch the boundary becomes accurate and self-describing:
name the backend and writable-root boundary, distinguish read-only mount
refusals (`EROFS`) from ordinary permissions (`EACCES`), and instruct the
model to report a confinement refusal instead of detouring around it.
That branch is a P0 work item (see § Phase P0), not a follow-up.
### D3 — Vendored binary committed to git, with a reproducibility CI gate
**Decision.** `qwen-landlock-run` binaries live at
`packages/core/vendor/landlock-run/{x64,arm64}-linux/qwen-landlock-run`,
committed to git — following the existing ripgrep/tree-sitter precedent
(`git ls-files packages/core/vendor/`). The C source lives alongside at
`packages/core/vendor/landlock-run/src/qwen-landlock-run.c` with
`scripts/build_landlock_run.mjs` (`musl-gcc -static -O2`). A CI job rebuilds
from source on both arches and fails if the committed bytes differ.
**Alternatives considered.**
- _Platform optional npm packages (dsh's model)_ — cleaner supply chain,
but adds a second publish pipeline; qwen-code already pays the
committed-binary cost for ripgrep, so one mechanism covers both. Revisit
if the vendored set grows.
- _Compile on install_ — rejected (dsh's words: "a fallback that exists
only where a compiler happens to be is not a fallback"; also our
`postinstall` is already load-bearing for ripgrep and must stay fast).
- _Commit binary without a rebuild gate_ — rejected: an unverifiable blob
is an unreviewable diff; the rebuild-and-compare CI job is the compromise
that keeps the ripgrep-style distribution honest.
### D4 — Enforcement honesty: `full` / `partial` / `none` is reported, never promised
**Decision.**
- Every backend probe reports an enforcement level: bwrap ⇒ `full` for
the selected mount policy (the functional probe exercises mount setup,
not isolation from all host capabilities); landlock ⇒ `full` or `partial` by kernel ABI negotiation (older
ABIs cannot govern newer access bits — e.g. `truncate` before ABI 3);
containers ⇒ `full` (their boundary is the runtime's problem).
- Explicitly requested sandbox (`--sandbox`/`QWEN_SANDBOX` naming a command,
or `=true`) whose probe fails ⇒ `FatalSandboxError` (existing behavior,
kept). **Silent unconstrained passthrough is never legal for an explicit
request.**
- Implicit absence (Linux, sandbox not requested) ⇒ run unconfined. No new UI
surface is added for it: `getSandboxEnv()` already answers `no sandbox`
(`systemInfo.ts:143-148`), and `headlessSafetyWarnings.ts` already covers the
headless-yolo case.
- The level travels into the sandboxed child via `SANDBOX_ENFORCEMENT` so
the UI can render it (e.g. `landlock (partial, kernel ABI 3)`).
### D5 — Network policy: three modes mapped onto the existing proxy hook
**Decision.** `open` (share net) / `closed` (`--unshare-net`) / `proxied`
(share net + inject `HTTP(S)_PROXY` toward a host-side
`QWEN_SANDBOX_PROXY_COMMAND`). Resolution: a non-empty `QWEN_SANDBOX_NET` that
is not one of `closed`/`open`/`proxied` is rejected at startup (`Invalid
QWEN_SANDBOX_NET …`) — a typo on the hard-deny switch must never fall through
to the least restrictive mode. Otherwise:
1. `QWEN_SANDBOX_NET=closed` ⇒ closed (hard deny, wins over proxy config).
2. `QWEN_SANDBOX_NET=proxied` without a non-blank `QWEN_SANDBOX_PROXY_COMMAND` ⇒ startup error.
3. else `QWEN_SANDBOX_PROXY_COMMAND` set ⇒ proxied.
4. else ⇒ open.
This mirrors the Seatbelt profile matrix (open/closed/proxied) without
introducing a new profile vocabulary in v1.
`closed` is a whole-network-namespace cut, not an egress filter: it also
removes loopback, so the IDE companion (discovered through a port in the
`~/.qwen/ide` lock file, `core/src/ide/ide-client.ts:644-678`), a host-side
`qwen serve` on `:4170`, and any localhost MCP server all become unreachable.
Abstract-namespace unix sockets are per-network-namespace too, so an X11
connection that resolves to the abstract socket dies with them. `closed` is
therefore correct only for single-shot confined runs; the § Verified
compatibility impacts table records this rather than hiding it behind a mode
name.
### D6 — No PID namespace, because PID liveness is load-bearing
**Decision.** The bwrap profile does **not** unshare the PID namespace, and no
switch is added to turn it on. `--proc /proc` is likewise absent — but not for
the reason an earlier draft of this document gave. That draft claimed mounting a
fresh procfs from inside a user namespace requires owning the PID namespace;
measured on kernel 7.0.0 with bwrap 0.11.1, `--proc /proc` **without**
`--unshare-pid` mounts fine, so the claim was wrong. The flag is omitted because
it is unnecessary and slightly looser: the recursive `--ro-bind / /` already
brings the host `/proc` in read-only — measured at 133 visible numeric entries,
which is all Node needs — whereas a fresh procfs instance would be mounted
read-write.
**Rationale.** qwen-code arbitrates cross-process ownership by PID. The following are examples, not an exhaustive list of readers:
- `cli/src/serve/conversations/conversation-runtime-ownership.ts:44-65` — an
owner record carries `pid`, and `processIsAlive()` treats anything that is not
`ESRCH` as alive;
- `cli/src/serve/live/discovery.ts:147` — the same predicate;
- `core/src/services/worktreeSessionService.ts:424-446` — the same predicate,
behind a `status.hostname !== os.hostname()` guard that conservatively answers
`active` for a record written by another machine.
Additional readers include `packages/core/src/services/session-writer-lease.ts` (`isProcessAlive`) and `packages/qwen-live/src/host/discovery.ts` (`processIsAlive`). Any future PID-namespace change must account for every producer and reader of shared ownership records, including readers added after this inventory.
These ownership records can be shared across the confinement boundary through the writable Qwen state directories.
Inside a private PID namespace the confined CLI's own PID is namespace-local
(1, 2, 3…). Writing that number into shared state is not merely useless, it is
**actively wrong**: host PID 2 is `kthreadd`, root-owned, so a host-side
`kill(2, 0)` returns `EPERM`, which every predicate above reads as _alive_. The
result is an owner that never appears dead, so handoff and reclaim never fire —
a silent hang rather than a visible failure. Isolation is not worth trading a
correctness invariant the daemon depends on, and the filesystem boundary is what
this design is actually for.
**Cost accepted.** The confined process can see and signal host processes, and
procfs magic links (`/proc/<pid>/root/…`) remain a way to name a host path.
That path is not read-only: the kernel reopens it against the _target_ task's
root mount, so for a same-uid host process it reaches the host's writable root,
subject to `ptrace_may_access` READ checks rather than this namespace's
read-only bind. Yama's ATTACH restrictions do not establish protection for
these links (see § Security considerations). On the Landlock
backend the ruleset governs the reopened path too.
**Precondition for ever adding it.** The owner record must first carry a
namespace identity and readers must treat "recorded in a different PID
namespace" as _unknown_, never as alive — the fail-closed direction for an
arbitration predicate. `worktreeSessionService`'s hostname guard is the shape to
follow (`/proc/self/ns/pid`'s inode is the standard handle). Until that exists,
a PID-namespace switch would be a footgun with no caller, so this design ships
no switch at all.
### D7 — Selection order and rollout
**Decision.** Named commands are always explicit: `QWEN_SANDBOX=bwrap` /
`landlock` / `docker` / `podman` / `sandbox-exec`. For
`--sandbox`/`QWEN_SANDBOX=true` (unnamed), candidate order is unchanged in
P0–P2 (Linux: `docker`, `podman` only — no behavior change for existing
users). **P3** flips the unnamed Linux candidate order to
`bwrap` → `landlock` → `docker` → `podman`, and (behind its own PR +
compatibility data) makes Linux sandbox auto-detected the way
`sandbox-exec` already is on macOS (`sandboxConfig.ts` (`getSandboxCommand`)), with
`QWEN_SANDBOX=false` (the existing spelling — `0` / `false` / empty are what
`getSandboxCommand()` accepts at `sandboxConfig.ts` (`getSandboxCommand`); an invented value
like `off` would be parsed as a command name and rejected) as the escape hatch.
**Rationale.** Preferring the kernel backends under an explicit `true`
changes what `--sandbox` means for docker users; doing that silently in P0
would be a behavior break. The flip is cheap once the backends are proven.
## Phase P0 — `bwrap` backend (pure TypeScript)
### Config surface
- `packages/core/src/config/config.ts`: widen `SandboxConfig.command` with
`'bwrap'` (`'landlock'` joins in P1 with its helper), and make
`image?: string` optional — required by convention only for container
commands, which `loadSandboxConfig` already enforces by returning no config
when a container command has no image (unchanged behavior).
- `packages/cli/src/config/sandboxConfig.ts`:
- `VALID_SANDBOX_COMMANDS` += `'bwrap'`.
- Generalize `runSandboxProbe` to per-command probe argv:
`docker`/`podman` → `['version']` (unchanged); `sandbox-exec` → skip
(unchanged); `bwrap` → functional probe
`['--ro-bind','/','/','--dev','/dev','--die-with-parent','--','true']`
— the same namespace-affecting flags the profile uses, so a probe pass
means the profile's namespace setup will succeed (D6: the profile carries
no `--unshare-pid`/`--proc`). The probe deliberately omits `--bind` and
`--chdir`, which depend on the resolved roots rather than on host
capability.
- Candidate list: unchanged in P0 (named-command path only).
### `start_sandbox` bwrap branch
New branch in `packages/cli/src/serve/sandbox.ts`, structurally mirroring
the seatbelt branch (`sandbox.ts` (`start_sandbox`, Seatbelt)):
1. **Writable roots** — the same set the Seatbelt permissive profile
grants, each `realpathSync`'d (the kernel compares resolved paths; the
seatbelt branch already canonicalizes for the same reason,
`sandbox.ts` (`start_sandbox`, `TARGET_DIR`)):
| Root | Value |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TARGET_DIR` | `realpathSync(process.cwd())` |
| `TMP_DIR` | `realpathSync(os.tmpdir())` — bound writable; `/tmp` is never replaced by a fresh tmpfs (see below) |
| `CACHE_DIR` | `XDG_CACHE_HOME` or `~/.cache` when empty/unset (create leaf only, then realpath) |
| `QWEN_DIR` | `Storage.getGlobalQwenDir()` (mkdir -p, realpath) |
| `RUNTIME_DIR` | `Storage.getRuntimeBaseDir()` (mkdir -p, realpath) |
| git dirs | `git rev-parse --git-dir` and `--git-common-dir`, when they resolve outside `TARGET_DIR` |
| npm cache | `~/.npm` if it exists |
| `INCLUDE_DIR_n` | `workspaceContext.getDirectories()` minus `TARGET_DIR` (no 5-cap; argv has no profile-parameter limit; entries resolving inside the home directory are refused) |
**Why the git roots.** In a worktree checkout `.git` is a file pointing
elsewhere, so the index, `HEAD`, reflogs, and objects all live outside the
workspace. Verified against this repository's own worktree layout:
```text
toplevel (cwd): /…/.qoder/worktree/qwen-code/o2qocx
--git-dir: /…/Projects/qwen-code/.git/worktrees/o2qocx
--git-common-dir: /…/Projects/qwen-code/.git
```
Without those roots every `git add` / `commit` / `stash` inside a worktree
workspace fails `EROFS`, taking `enter_worktree`, Arena, and `/review` down
with it — and worktrees are a first-class qwen-code workflow, not an edge
case. Resolution runs once at hop time; a non-repo cwd yields nothing and
contributes no roots. Note the asymmetry this repairs: none of the six
Seatbelt profiles grants a git common dir either, so macOS has the same hole
today. Fixing Seatbelt is out of scope (§ Non-goals), but the gap is now
recorded.
**Why `/tmp` is bound, not tmpfs.** A fresh `--tmpfs /tmp` would contradict
the `TMP_DIR` row above and mask paths the host puts there:
`/tmp/.X11-unix` (no GUI launch) and `/tmp/ssh-*/agent.*` (no ssh-agent auth
for `git push`). Since `os.tmpdir()` is already a writable root and host
`/tmp` is world-writable anyway, a private tmpfs buys little and costs two
visible features, so it is not offered — not as a default and not as a
switch.
Paths the container branch mounts read-only — `~/.config/gcloud` and a
`GOOGLE_APPLICATION_CREDENTIALS` file (`sandbox.ts` (`start_sandbox`, `GOOGLE_APPLICATION_CREDENTIALS`)) — need no
counterpart here: `--ro-bind / /` already makes them readable, and they were
never writable in the container either.
The Seatbelt profile additionally grants `/dev/stdout`, `/dev/stderr`,
`/dev/null`, `/dev/ptmx`, `/dev/ttys*` (`sandbox-macos-permissive-open.sb:23-27`);
bwrap's `--dev /dev` covers that set (null/zero/full/random/urandom, tty,
pts/ptmx, and the std\* symlinks). It also masks everything else under
`/dev`, including `/dev/snd` — voice input is unavailable under confinement
(§ Verified compatibility impacts).
Roots that don't exist are skipped (bwrap fails on a missing bind
source); roots already covered by an earlier root are dropped.
2. **argv template** (the shape dsh uses in
`packages/sandbox/sandbox-local/src/profiles.ts`):
```text
bwrap
--ro-bind / / # recursive: /proc /sys /run come along read-only
--dev /dev
--die-with-parent
[--unshare-net] # closed mode only
--bind <root> <root> # one pair per writable root
--chdir <TARGET_DIR>
-- <cliArgs...> # process.argv with the stdin /
# session-id injections llm.tsx
# already performs
```
3. **Child environment** — carried on the `spawn` env rather than through
`bwrap --setenv`, so one code path serves both backends (the landlock helper
execs with the launcher's environment unchanged) and nothing depends on a
bwrap version that has a given flag:
- `SANDBOX=bwrap` (or `qwen-landlock-run`) and `SANDBOX_ENFORCEMENT`.
- `NODE_OPTIONS` merged exactly as the seatbelt branch does.
- proxy variables in proxied mode.
- **deleted**: `DISPLAY`, `WAYLAND_DISPLAY`, `MIR_SOCKET`.
`shouldAttemptBrowserLaunch()` (`core/src/utils/browser.ts:52-60`) decides
on Linux purely by the presence of those three. Left set, an OAuth login
inside the sandbox would `xdg-open` a browser _as a confined child_, which
then cannot write its own profile directory (`~/.config/<browser>` is not
a writable root) and fails in a way that reads like an auth bug. Deleting
them makes the existing print-the-URL path deterministic and the user
opens the link in their normal host browser.
4. **Proxy** — proxied mode starts the host-side proxy the way the seatbelt
branch does (`sandbox.ts` (`start_sandbox`, Seatbelt proxy): spawn it detached, install
exit/SIGINT/SIGTERM handlers that kill the process group). The common bwrap runner waits up to 30 seconds for the configured proxy URL (default `http://localhost:8877`) to answer and injects `HTTP(S)_PROXY` **on the spawn env**.
Both keys that drive this lifecycle — `QWEN_SANDBOX_PROXY_COMMAND` and
`QWEN_SANDBOX_NET` — are confinement decisions and sit in
`PROJECT_ENV_HARDCODED_EXCLUSIONS`: the proxy command runs through
`bash -c` on the host, outside the confinement, before the agent starts,
so it may only originate from the operator's launch environment or a
home-scoped `.env`, never from repository content.
The seatbelt block is deliberately _not_ shared as-is. It builds a
`sandboxEnv` object, writes the proxy variables into it
(`sandbox.ts` (`start_sandbox`, Seatbelt `sandboxEnv`)), and then never passes it to `spawn` — that call uses
`{ ...process.env, ...childEnv }` (`start_sandbox`). On macOS the proxy variables
are therefore computed and dropped, so `QWEN_SANDBOX_PROXY_COMMAND` starts a
proxy the confined process is never told about. `noUnusedLocals` does not
catch it because indexed assignment counts as a use.
That is a pre-existing defect rather than something this phase introduces,
and fixing it changes macOS behavior — out of scope here (§ Non-goals: no
change to the Seatbelt path), recorded in § Open questions instead. The bwrap
branch and the sandbox subcommand share `runBwrap` and `buildBwrapEnv`, passing the variables through the child env. Sharing this runner with Seatbelt remains a follow-up after its environment bug is fixed.
5. **Spawn contract** — `stdio: 'inherit'`, `process.stdin.pause()` before spawn / `resume()` on close, resolve with the child's exit code. The asynchronous runner uses POSIX `detached: true` to create a session without the host controlling terminal; synchronous verification probes pass bwrap `--new-session`. The asynchronous runner forwards SIGINT/SIGTERM to the sandbox process group, maps signal exits to 130/143, and relays SIGWINCH so terminal resizing still reaches the TUI. Cancellation during proxy readiness stops the proxy without launching a late payload. This session behavior is specific to bwrap; the Seatbelt path is unchanged.
### `llm.tsx` handoff
`llm.tsx` (`main`, sandbox handoff) branches on `sandboxConfig.command !== 'sandbox-exec'`
to decide the update-relaunch env handoff (container vs non-container).
Widen the non-container side: `bwrap`/`landlock` must take the
sandbox-exec branch (they are in-place hops, not images — no
`CUSTOM_SANDBOX_IMAGE_ENV_VAR`).
### UI
`packages/cli/src/ui/systemInfo.ts` needs **no change in P0**:
`getSandboxEnv()`'s final `return sandbox` (`:156`) already renders an unknown
`SANDBOX` value verbatim, so `bwrap` displays correctly as-is. The enforcement
suffix (`landlock (partial)`) lands with P1, which is the phase that can
actually produce a level other than `full` — shipping the suffix earlier would
mean an unreachable branch for a backend that does not exist yet. P0 still
surfaces the level where it is meaningful: `qwen sandbox` prints
`Enforcement: <level>` from `SANDBOX_ENFORCEMENT`.
### Model-visible boundary
`packages/core/src/core/prompts.ts` (`getCoreSystemPrompt`): add a branch for the in-place
kernel backend. Without it `SANDBOX=bwrap` lands in the generic branch, which
tells the model it runs "in a sandbox container" and teaches it to look for
`Operation not permitted` — the wrong shape for a bwrap denial. The new branch
states the backend, that the host root is read-only outside the writable roots,
the `Read-only file system` (`EROFS`) refusal, and the distinction that
`Permission denied` (`EACCES`) can be ordinary permissions inside a writable
root. Report a confinement refusal rather than detouring around it. Rationale in D2. The branch matches `bwrap` only; P1 adds
`qwen-landlock-run` to it together with the enforcement wording, for the same
reason the UI suffix waits.
The measured spelling matters. A denied `git add` in a worktree checkout reads
`fatal: Unable to create '…/index.lock': Read-only file system`, so the branch
must name `Read-only file system` in prose — naming only the `EROFS` symbol
would not match what the model actually sees.
### `qwen sandbox` self-check subcommand
New `packages/cli/src/commands/sandbox.ts` (+ test), registered beside the
existing command modules. Pulled into P0 rather than deferred: every impact in
§ Verified compatibility impacts is invisible until something fails mid-task, so
the backend and the way to prove it works have to ship together — this is also
what the proposed CI lanes and E2E table below call. The Linux bwrap CI lane is not implemented in this PR.
Use `QWEN_SANDBOX=bwrap qwen sandbox` from the project directory on the host to inspect this backend explicitly. A one-shot environment assignment on the original session does not persist in the host shell. The report uses that directory's settings; session-only `--include-directories` grants can differ.
- `qwen sandbox` — print resolved backend, probe result, enforcement level,
writable roots (including the resolved git dirs), and network mode.
- `qwen sandbox <cmd>…` — run one command through the resolved backend and
report the outcome (the `codex sandbox` equivalent).
- `qwen sandbox --verify` — the behavior battery: write outside the workspace
must fail; write inside must succeed; the payload must report the same `/proc/self/ns/pid` identity as the host (the regression guard for D6); unreadable identities fail verification;
network checks inspect interface visibility: `closed` must expose only `lo`, while `open`/`proxied` must expose a non-loopback interface. They do not test Internet reachability, proxy availability, or exclusive proxy routing; those require separate integration checks.
### Review follow-up contract
- Reject a canonical writable root equal to the home directory or any ancestor, including `/`, before spawning bwrap. The rule also applies through symlinks and to additional workspace directories, which get a stricter floor: any entry resolving inside the home directory is refused, because workspace-scope settings (`context.includeDirectories`) are repository content and must not pick home-internal roots such as `~/.ssh` or a sibling checkout carrying a `.git`. Entries already covered by a built-in root (the workspace itself, the cache/runtime dirs) stay admitted. Use a narrower workspace or cache path instead of silently granting the whole home or host filesystem.
- Derive Git roots with the shared `gitEnv()` sanitizer so ambient repository selectors cannot redirect the probe. Only a real `.git` directory or a linked worktree registered under the common repository with a matching reverse pointer receives automatic Git grants. Symlinked metadata, planted gitfiles, separate Git directories, and submodule gitfiles without that registration contribute no roots; users must explicitly include any required external metadata directory. Legitimate linked worktrees retain their entire common Git directory grant. A repository rooted at HOME also retains its `.git` grant when a child directory is the workspace. These layouts have explicit tests; the inspection report and model prompt disclose that granted config and hooks can affect later unconfined Git commands. This is a retained permission policy, not a narrower metadata allowlist.
- Inspection forwards the explicit sandbox and image flags, skips settings and `.env` loading in bare mode, and suppresses settings in safe mode. Plain inspection may exit successfully without a backend; a verification or command request that cannot run exits non-zero, including when already confined. Commands with their own flags must follow `--`; unknown flags before it fail parsing.
- Pass-through commands inherit stdin, stdout, and stderr directly. Inspection text goes to stderr for this mode, so large output and piped structured output are preserved. The verification battery retains captured output for its predicates.
- Actual command execution and the normal bwrap hop share the launcher and child environment: runtime markers are set, display variables are removed, and proxied mode starts the configured proxy, normalizes proxy variables, waits for readiness, and stops the proxy on completion or failure. Proxy startup/readiness failure prevents payload launch; proxy exit after launch terminates the payload and fails the request. Plain inspection starts neither process; captured verification uses the same environment policy plus `LC_ALL=C`, and its interface check does not start or validate the proxy.
- The bwrap re-exec appends Node launch options to the inherited options, preserves child-environment precedence, and restores Electron's Node mode when the managed launch marker requests it.
- `full` describes filesystem mount enforcement, not isolation from host services. The inspection report and prompt state that host Unix sockets remain reachable, including in closed network mode, and that proxied mode does not enforce exclusive proxy use. Writable Git configuration/hooks and Qwen settings can affect later unconfined launches; this residual capability is retained with the existing grants. Narrowing repository metadata remains a separate decision; bwrap no longer grants global `~/.gitconfig` writes by default.
- Regression acceptance: broad roots and symlinks are refused; ambient Git selectors and planted gitfiles cannot grant an unrelated repository; failed-to-run requests are non-zero; command flags are preserved or rejected; pass-through output is not captured; inherited Node options and managed Electron mode reach the child. Linux mount enforcement and the procfs edge case require a Linux host and are not claimed from mocked spawn tests.
## Phase P1 — `qwen-landlock-run` vendored fallback
### The helper (new source, ~300 lines C11)
`packages/core/vendor/landlock-run/src/qwen-landlock-run.c`. The design
follows dsh's `native/system/packages/entry/src/main.c` (BSD-3-Clause;
re-implemented, not copied):
- CLI contract (pinned in the file header and unit-tested from TS):
```
qwen-landlock-run [--ro <path>]... [--rw <path>]... -- <argv>...
qwen-landlock-run --probe
```
`--ro` grants read+execute beneath the path; `--rw` grants every
filesystem access the negotiated kernel ABI governs. Everything else is
denied (Landlock rulesets are allow-lists).
- **Fail-closed**: `no_new_privs` → `landlock_create_ruleset` (ABI
negotiation from max known down) → `add_rule` per grant →
`landlock_restrict_self` → `execvp`. Any failure exits **125** with a
`qwen-landlock-run: ` stderr prefix, without exec'ing. An unopenable
grant root is a launch failure, never a silently narrowed profile.
- **Functional probe**: `--probe` installs a maximal ruleset on itself and
prints exactly one line — `landlock: fully enforced` or
`landlock: partially enforced (older ABI)` — exit 0; exit 125 when the
kernel cannot enforce (ENOSYS/EOPNOTSUPP). A `--version`-style check
would miss kernels that have the syscalls but refuse enforcement.
- **Partial enforcement**: on an older ABI, prints
`qwen-landlock-run: partial enforcement (older Landlock ABI)` to stderr
and proceeds — still confined for everything the kernel governs.
- UAPI structs/constants defined locally (kernel userspace ABI is stable;
keeps the build independent of toolchain header vintage, and the
definitions double as the audit record).
### Build & distribution
- `scripts/build_landlock_run.mjs` builds one arch at a time:
```sh
musl-gcc -static -O2 \
-o packages/core/vendor/landlock-run/<arch>-linux/qwen-landlock-run \
packages/core/vendor/landlock-run/src/qwen-landlock-run.c
```
The toolchain is documented alongside it (`apt install musl-tools` on Linux,
`brew install musl-cross` on macOS).
- CI (`verify-landlock-run.yml`, ubuntu x64 + arm64 runners): rebuild from
source, `sha256sum` compare against the committed binaries, run the
functional probe, and run the behavior battery (deny write outside
`--rw`, allow inside, `--probe` exit codes on a Landlock-capable kernel).
- `packages/core/package.json` `files` already includes `vendor/` — no
publish change needed.
### TS integration
- Registration: `SandboxConfig.command` gains `'landlock'`;
`VALID_SANDBOX_COMMANDS` += `'landlock'`; `runSandboxProbe` gains the
`landlock` → `[launcherPath(), '--probe']` arm (a missing helper or a
non-enforcing kernel is a probe failure, so a named `landlock` request
fails closed with the existing `FatalSandboxError` wording).
- `packages/cli/src/utils/landlockRun.ts` (new):
`launcherPath()` (arch resolution via `resolveBundleDir`, mirroring
`getBuiltinRipgrep`'s traversal rules in
`packages/core/src/utils/ripgrepUtils.ts:97-111`),
`probe(): Promise<'full' | 'partial' | 'unusable'>`, and
`grantArgs(roots: {ro: string[]; rw: string[]}): string[]`.
- `start_sandbox` `landlock` branch: argv =
`[launcherPath(), ...grantArgs({ro: ['/'], rw: writableRoots}), '--', ...cliArgs]`
with `--setenv`-free env inheritance (helper execs with launcher's
environment unchanged; set `SANDBOX=qwen-landlock-run` and
`SANDBOX_ENFORCEMENT` in `process.env` for the child before spawn, same
as the seatbelt branch's env merge).
- Enforcement plumbing: probe result is threaded into the child env so
`systemInfo.ts` can render `landlock (partial, kernel ABI 3)`.
## Phase P2 — seccomp tightening in the helper
**seccomp in the helper** (+~80 lines C): after `no_new_privs`, install a
minimal BPF filter denying `ptrace`, `process_vm_readv`, `process_vm_writev`,
`mount`, `umount2`, `init_module`,
`finit_module`, `delete_module`, `kexec_load`, `kexec_file_load`, `bpf`,
`perf_event_open`, `keyctl`, `iopl`, `ioperm`. This is the Claude Code
`apply-seccomp` pattern folded into the same helper; it applies to the landlock
path only. bwrap has no seccomp hook of its own beyond `--seccomp <fd>`, which
would mean shipping a compiled BPF program to feed it — out of scope here.
Because the helper gains a denial it did not have before, this phase is a
behavior tightening on an existing named backend, not a pure addition: it needs
a release-notes callout and a `--verify` case per denied syscall class.
## Phase P3 — default flip (own PR, behind compatibility data)
1. Unnamed `--sandbox`/`QWEN_SANDBOX=true` on Linux: candidate order
becomes `bwrap` → `landlock` → `docker` → `podman`.
2. Auto-detected sandbox on Linux (mirror of the macOS `sandbox-exec`
auto-candidate at `sandboxConfig.ts` (`getSandboxCommand`)), gated behind a review of
§ Verified compatibility impacts with field data behind it — not just the
mechanism list, but how often real workflows hit each row.
`QWEN_SANDBOX=false` is the documented escape hatch.
3. CI: add `test:integration:sandbox:bwrap` and
`test:integration:sandbox:landlock` lanes; keep `sandbox:none` lanes
(the unconfined path remains supported), but it stops being the only
exercised Linux configuration.
## Verified compatibility impacts
Each row is a mechanism verified against this repository, not a guess, and
applies to the two in-place backends only — container backends and macOS are
unchanged. This is the table the P3 default flip must answer to; until then
these are documented consequences of an opt-in flag.
| Area | Impact | Mechanism | Handling in v1 |
| ------------------------------------------------------------------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| git in a worktree checkout | would break entirely | git dir and common dir resolve outside cwd (verified layout in § Phase P0) | **fixed** — both are writable roots |
| Cross-process owner liveness | would hang silently | `process.kill(pid, 0)` with non-`ESRCH` ⇒ alive, over PIDs shared through `~/.qwen` (`conversation-runtime-ownership.ts:44-65`, `serve/live/discovery.ts:147`, `worktreeSessionService.ts:424-446`) | **avoided** — no PID namespace at all (D6) |
| GUI launch (OAuth login, `artifact` open) | browser cannot run confined | `xdg-open` child inherits confinement and `~/.config/<browser>` is not a writable root | display vars deleted ⇒ deterministic print-the-URL path |
| ssh-agent auth for `git push` | unaffected | the agent socket usually lives under `/run/user/$UID` (kept reachable by the read-only host bind); a `/tmp/ssh-*` socket also keeps working because host `/tmp` stays bound rather than replaced by a tmpfs — see § Security considerations for what sharing `/tmp` exposes | — |
| Voice input | unavailable | `--dev /dev` masks `/dev/snd`; `voice-availability.ts:50` also needs `PULSE_SERVER` | documented — use a container backend or run unconfined |
| Self-update | partly affected | the default managed update root is `QWEN_DIR/updates/npm` (Qwen home follows `QWEN_HOME`); staging, versions, and the active manifest must be writable, and the host launcher later executes that code; custom pinned update roots may lie outside the grants and npm-global updates write the global prefix | retain the default managed update grant and its host-execution risk; update from the host when the installation target is outside writable roots |
| IDE companion, host `qwen serve`, localhost MCP | unreachable in `closed` only | `--unshare-net` removes loopback (D5) | `open` / `proxied` keep them working |
| Writes to `~/.config`, `~/.kube`, `~/.ssh`, `/usr/local` | denied | not writable roots — this is the point of the sandbox | extra home-internal roots are refused; non-home paths can be granted with `--include-directories` |
| Session data, checkpoints, worktrees, Arena, IDE lock file, MCP OAuth tokens, token ledger | unaffected | all under `QWEN_DIR` / `RUNTIME_DIR` (`storage.ts:193-241`, `gitWorktreeService.ts:419`, `ArenaManager.ts:136`) | — |
| `docker.sock`, D-Bus, Wayland and other host unix sockets | reachable on bwrap (measured) | the kernel's read-only-fs write check exempts `S_ISSOCK` (only `S_ISREG`/`S_ISDIR`/`S_ISLNK` yield `EROFS`), so a socket under `--ro-bind / /` still accepts `connect()` | **verified** on bwrap — a socket outside every writable root still accepted `connect()`; the Landlock backend stays a P1 `--verify` case |
| procfs magic links (`/proc/<pid>/root/…`) | outside the mount policy | access uses `PTRACE_MODE_READ_FSCREDS`, not Yama's ATTACH relationship restriction | **retained limitation** — do not infer protection from `ptrace_scope=1`; stronger isolation needs the D6 prerequisite |
The extra-directory restriction applies equally to CLI and settings entries because the resolver receives merged paths without source provenance. Home-internal entries outside the built-in grants stop startup instead of silently downgrading a requested writable directory. Non-home extra roots remain writable even when declared by workspace settings; P0 does not claim operator-only grants. Separating explicit operator grants from repository settings remains a separate configuration-policy change.
## Security considerations
- **Canonicalization**: every writable root is `realpathSync`'d before
entering the profile; bwrap/Landlock compare resolved paths. The
seatbelt branch does the same for the same reason.
- **procfs magic links**: remain outside this mount policy because the
profile shares the host PID namespace (D6). Links resolve against the
target task's mount, and access is subject to credentials, dumpability,
capabilities and LSM checks. Linux 7.0 checks procfs links with
`PTRACE_MODE_READ_FSCREDS`; Yama's relationship restrictions apply to
`PTRACE_MODE_ATTACH`. Therefore `kernel.yama.ptrace_scope=1` does not
establish that these links are blocked. See the [procfs access check](https://github.com/torvalds/linux/blob/v7.0/fs/proc/base.c#L708)
and [Yama access check](https://github.com/torvalds/linux/blob/v7.0/security/yama/yama_lsm.c#L349).
Following a link does not call `ptrace(2)`, so denying that syscall alone
does not close this path. Process-memory APIs use different access modes
and must be assessed separately. P0 does not claim isolation from host
processes; stronger isolation remains follow-up work with the D6 owner
record prerequisite. These source checks are not a dynamic verification
of every supported host.
- **Read-only-fs semantics are narrower than they look**: the kernel returns
`EROFS` for write access only to `S_ISREG` / `S_ISDIR` / `S_ISLNK`. Sockets,
FIFOs, and device nodes under `--ro-bind / /` are exempt, so host unix
sockets remain connectable — measured, not assumed (§ Evidence). That is
deliberate here (it is what keeps `docker.sock`, D-Bus, and Wayland usable)
but it means "read-only root" must not be read as "no IPC out of the
sandbox": anything reachable over a host socket is outside this boundary by
construction. Concretely in `closed` mode: a raw TCP connect fails with
`ENETUNREACH`, but DNS still resolves through the host's `systemd-resolved`
pathname socket — not a network-namespace object, so `--unshare-net` does
not cut it (measured on the PR's independent verification host, Debian 13).
A DNS-shaped side channel therefore remains in closed mode.
- **setuid escalation**: `no_new_privs` is set before restricting (also
mandatory for unprivileged Landlock).
- **Terminal session**: both bwrap launch sites create a separate POSIX session, while retaining standard I/O. This removes ownership of the host controlling terminal, addressing the terminal-session concern described in the [bubblewrap security documentation](https://github.com/containers/bubblewrap#sandbox-security). The asynchronous launcher uses `detached: true`, so bwrap and its payload stay in the same process group for signal forwarding. Synchronous probes use bwrap `--new-session`; they do not use the asynchronous signal relay. This does not add PID or host-process isolation.
- **Orphans**: `--die-with-parent` on bwrap; Landlock rulesets are
process-scoped and vanish with the process.
- **Symlink swap between resolve and exec**: narrowed by resolving
immediately before spawn; the residual race is the same class the
seatbelt path already accepts (documented in `sandbox.ts`).
- **Env**: the bwrap/landlock hop inherits the full environment (parity
with the seatbelt branch). Credential stripping stays with the existing
`sanitize-child-env` consumers; this design does not change that
boundary.
- **Env-sourced confinement inputs**: the writable-root derivation reads
`XDG_CACHE_HOME` and (via `os.tmpdir()`) `TMPDIR`/`TMP`/`TEMP`, and the
network mode and proxied lifecycle read `QWEN_SANDBOX_NET` /
`QWEN_SANDBOX_PROXY_COMMAND`. All of them sit in
`PROJECT_ENV_HARDCODED_EXCLUSIONS`, so a project `.env` or settings.env
cannot pick the confinement roots or the host-side proxy command; only the
operator's launch environment or a home-scoped `.env` can. Proxied mode
executes that command through `bash -c` on the host, outside the
confinement, before the agent starts.
- **Non-directory grants (P1 helper)**: a file grant keeps only
file-compatible access bits (the kernel rejects directory-only accesses
on non-directories with EINVAL — the helper masks accordingly).
- **Writable Git metadata is later executed on the host**: the git dir /
common-dir grant lets a confined
process rewrite `hooks/` and `config` — `core.fsmonitor`, `core.hooksPath`,
`core.pager`, aliases, hook scripts — which a later _unconfined_ `git`
invocation runs as the user. The grant is retained because worktree
commits need `objects/`/`refs/`/`logs/` writable; narrowing it to exclude
hooks/config remains follow-up work (§ Review follow-up contract). The
bwrap defaults do not grant writes to `~/.gitconfig`: the read-only host
root already provides read access. Seatbelt profiles are unchanged.
- **Host `/tmp` is a shared read-write grant**: `os.tmpdir()` stays bound
rather than replaced by a tmpfs, so same-user socket and lock paths under
it (`/tmp/.X11-unix`, `/tmp/ssh-*/agent.*`) can be unlinked and replaced
from inside — a confined process could answer a later unconfined
`git push`'s signature requests, or intercept the host GUI's display
socket. Retained deliberately: a private tmpfs would break GUI launches
and ssh-agent auth (§ P0 writable roots). Operators who do not need those
can point `TMPDIR` at a per-session directory before launching.
- **The QWEN_DIR `.env` is pinned read-only inside the confinement**:
QWEN_DIR is a writable root (sessions, checkpoints, worktrees, lock
files), but its `.env` is operator-trusted execution input that every
later launch re-reads — including `QWEN_SANDBOX_PROXY_COMMAND`, which
proxied mode executes through `bash -c` on the host. A read-only bind
layered over the writable root keeps a confined process from rewriting
what the host next trusts through that path. If absent, root resolution
creates an empty file exclusively with mode 0600 before binding it;
existing contents are never overwritten. The empty file remains on the
host, including after inspection, so later resolutions use the same path.
Non-regular files, symbolic links, multiple hard links, and failures to
prepare the file stop the operation rather than omit the protection.
- **Other Qwen state remains writable and trusted by later host runs**: the `.env` override protects only that environment-input path. User `settings.json` (including MCP and hook configuration), custom commands, memory, and managed update files remain writable. Settings changes can affect later unconfined launches, and the managed launcher executes code selected from its update root. These are retained capabilities for persistent configuration and managed updates, not a claim that all future host execution inputs are protected. Narrowing them requires a separate write-policy and update-handoff design.
- **Runtime markers are launcher-owned**: `SANDBOX` and
`SANDBOX_ENFORCEMENT` are rejected from project and home `.env` files and
from settings.env, including reloads and runtime snapshots. Existing
launcher values remain inherited; backend selection through the separate
`QWEN_SANDBOX` operator setting is unchanged.
- **Launcher/command failure attribution**: launcher failures exit 125
with a `qwen-landlock-run: ` prefix; a successfully exec'd child may
also exit 125, so consumers require status 125 **and** the prefix —
same rule dsh's CLI contract pins.
## Test plan
The delivered P0 unit coverage and planned integration work are listed separately. P1 and later phases remain plans for separate PRs.
**P0 — delivered unit tests (vitest)**
- `packages/cli/src/config/sandboxConfig.test.ts`: explicit bwrap selection without an image, functional-probe argv and timeout, probe failure and caching, invalid names, and the Seatbelt no-image path.
- `packages/cli/src/serve/sandbox-bwrap.test.ts`: bind source/destination and ordering, canonical root deduplication and refusal, Git provenance and containment, registered-worktree reverse pointers, HOME-rooted dotfiles repositories, absence of `--unshare-all` / `--unshare-pid` / `--proc` / `--tmpfs`, network-mode flags, and normal-hop child environment and proxy cleanup. Git fixtures isolate both ambient repository selectors and user/system Git configuration; filesystem fixtures keep HOME, temporary storage, and workspace separate.
- `packages/core/src/core/prompts.test.ts`: bwrap wording includes `EROFS`, retained Git metadata capability, and host-service limits; other backends retain their expected text.
- `packages/cli/src/commands/sandbox.test.ts`: inspection output, final positional plus `--` command ordering, execution failure, and failed verification predicates including missing loopback.
- `packages/cli/src/commands/sandbox-command-runtime.test.ts`: real handler/runner with mocked processes verifies markers, display removal, inherited stdio/argv/exit status, proxy normalization/startup/readiness/failure/cleanup, and diagnostic-only inspection. These tests do not establish Linux mount enforcement.
**P0 — integration follow-up, not delivered in this PR**
The proposed `test:integration:sandbox:bwrap` script and Ubuntu workflow installation of `bubblewrap` are absent from this PR. A follow-up should run fake-LLM-server tests inside the real confinement (workspace writes and connectivity to the fake server). The dated manual Linux observations in § Evidence do not substitute for that CI lane; local mocked tests do not add new Linux enforcement evidence.
**P1 — unit**
- `landlockRun.test.ts`: probe line → `full` / `partial` / `unusable` mapping;
`grantArgs` shape; arch resolution (mocked platform/arch, the pattern in
`ripgrepUtils.test.ts`).
- `sandbox.test.ts`: landlock argv = launcher + grants + `--` + cliArgs.
- `systemInfo.test.ts`: `landlock (partial, …)` rendering, plus the no-suffix
case guarding the verbatim fallthrough at `:156`.
- `prompts.test.ts`: `SANDBOX=qwen-landlock-run` selects the kernel-sandbox
branch and the `partial` wording appears only when the level says so.
**P1 — build gate and integration**
- `verify-landlock-run.yml`: rebuild from source → sha256 compare against the
committed binaries → functional probe → behavior battery, on both arches.
- `test:integration:sandbox:landlock` lane.
**P2**
- One `--verify` case per denied syscall class, plus a battery case proving the
helper still execs successfully for allowed syscalls (a seccomp filter that
denies everything would otherwise pass the deny cases).
**P3**
- `sandboxConfig.test.ts`: unnamed `--sandbox` / `QWEN_SANDBOX=true` on Linux
resolves in the new candidate order; `QWEN_SANDBOX=false` still disables
everything; auto-detection does not fire on non-Linux.
**E2E (manual, per release until P3)**
| Check | Expected |
| -------------------------------------------------- | -------------------------------------------------------------------- |
| `QWEN_SANDBOX=bwrap qwen` on a dockerless Linux VM | starts; status line shows `bwrap` |
| inside a **worktree** checkout: `git commit` | works (regression guard for the git roots) |
| a second CLI while a confined one holds a session | owner handoff still fires (regression guard for D6) |
| inside: `touch /usr/local/bin/x` | EROFS |
| inside: `touch ./x && rm ./x` (workspace) | works |
| inside: `ls /proc` | host processes visible (no PID namespace — D6) |
| `QWEN_SANDBOX_NET=closed`: model API call | fails fast with a clear proxy hint |
| proxied: API call via `QWEN_SANDBOX_PROXY_COMMAND` | works |
| machine with `kernel.unprivileged_userns_clone=0` | named `bwrap` ⇒ "installed but cannot run"; named `landlock` ⇒ works |
| kernel < 5.13 (no Landlock) | `landlock` probe ⇒ unusable; explicit request ⇒ `FatalSandboxError` |
| Ctrl-C / CLI crash | no orphaned confined processes |
| `qwen sandbox --verify` | battery passes |
## Rollout & compatibility
- P0: no default behavior change on any platform; `bwrap` available by name.
Additions only — `VALID_SANDBOX_COMMANDS`, one `start_sandbox` branch, the
type widening, the handoff branch widening, the prompt branch, and the
`qwen sandbox` subcommand.
- P1: adds vendored binaries (~70 KB × 2 arches) and the `landlock`
named command; no default behavior change.
- P2: tightens the landlock backend with seccomp — the one phase before P3 that
changes behavior for an already-shipped named backend; release-notes callout.
- P3: the only phase that changes what happens by default; own PR, own design
review, own release-notes callout.
**Process note.** `packages/cli/src/config/sandboxConfig.ts` and
`packages/core/src/config/config.ts` both match `packages/*/src/config/**`, so
every phase touches core infrastructure under the AGENTS.md two-tier gate. None
of the phases is a large-scope `refactor`, so none is hard-blocked, but an
externally authored PR here escalates to a maintainer by rule rather than by
judgement. Splitting the work as P0–P3 also keeps each PR inside the
CONTRIBUTING.md size thresholds.
## Open questions
1. **WSL2**: Landlock ABI availability varies by WSL2 kernel vintage;
probe handles it correctly, but the P3 auto-default needs WSL2 in the
compatibility matrix.
2. **Snap/Flatpak-confined hosts**: userns may be restricted even for
root; the landlock fallback covers most such cases, but a
"neither works" host needs its error message to name both failures.
3. **Restrictive write-set profile**: the macOS matrix has
`restrictive-*` profiles (97-line `.sb` files); whether Linux needs an
equivalent narrower root set in v1 or can defer to the profile
unification work is a P3 scoping question.
4. **Per-command confinement**: Codex-grade per-call policies (read-only
for auto-approved commands, workspace-write after approval) require
the tool-scheduler surgery described in D2. Worth its own design once
P0–P3 usage data exists.
5. **Landlock and socket files**: on the bwrap backend a host unix socket under
the read-only root is reachable (measured, § Evidence). Whether a Landlock
ruleset that grants no access to a socket's directory blocks `connect()` is
still open, and it decides whether the two backends have the same IPC
surface — a P1 `--verify` case rather than an assumption.
6. **The Seatbelt git-dir gap**: the six macOS profiles grant no git common dir
either, so `git commit` in a worktree checkout should already fail under
`--sandbox sandbox-exec` today. If that reproduces, it is a standalone bug
fix (a profile parameter, not a design change) and should not be bundled into
this work.
7. **The Seatbelt proxy-env drop**: `sandbox.ts` (`start_sandbox`, Seatbelt `sandboxEnv`) fills a `sandboxEnv`
object with the proxy variables that `start_sandbox` never passes to `spawn`, so
`QWEN_SANDBOX_PROXY_COMMAND` on macOS starts a proxy the confined process
cannot see. Also a standalone fix — it changes macOS behavior and wants its
own regression test, so it is deliberately not bundled here.
8. **Optional cache creation under `/proc`**: the verification VM reported a hang with recursive Node directory creation at `/proc/nope/cache`. The cache path now uses a single non-recursive `mkdir`: an absent parent is not created and the optional root is dropped. The normal missing `~/.cache` leaf is still created. This follow-up was checked with ordinary missing-parent and first-run cases on macOS; the Linux procfs case still requires a real Linux verification. Required Qwen and runtime directory creation is unchanged.
## Evidence
- Live verification on Linux, 2026-09-10 — Lima VM, kernel `7.0.0-28-generic`,
bubblewrap 0.11.1, git 2.53.0, `unprivileged_userns_clone=1`, Landlock present
in `/sys/kernel/security/lsm`. The probe script lived at
`.qwen/scripts/verify-bwrap-assumptions.sh`, which `.gitignore` excludes by
repository convention, so it is not part of this change — the measurements it
produced are recorded below instead, and `qwen sandbox --verify` is the
committed four-check subset, not a replacement for the manual Git and socket checks. The original probe
exercised ten claims from this document: nine passed and one failed (the
`--proc` claim in D6, corrected in place above). Confirmed by measurement:
the documented probe argv launches; a write outside the roots returns
`EROFS`; a `--bind` root is writable; host PIDs stay visible without
`--unshare-pid` (133 numeric `/proc` entries); a host unix socket outside
every writable root still accepts `connect()`; `git add` in a worktree
checkout fails with `Unable to create '…/index.lock': Read-only file system`
when only the worktree is bound and succeeds once the git dir and common dir
are bound; `/dev/snd` is masked inside while present on the host;
`--unshare-net` blocks outbound resolution.
- This repo: Phase 0 records the pre-implementation inspection from 2026-09-09. Changed-code references now use current file/symbol locations; remaining line references below identify that historical inspection, not a fresh verification of the current branch. The impact sweep behind D2, D5, D6 and § Verified compatibility
impacts was added 2026-09-10 and covers
`cli/src/serve/conversations/conversation-runtime-ownership.ts:44-65`,
`cli/src/serve/live/discovery.ts:147`, `core/src/core/prompts.ts` (`getCoreSystemPrompt`),
`core/src/utils/browser.ts:25-70`, `core/src/utils/secure-browser-launcher.ts:151-181`,
`core/src/ide/ide-client.ts:644-678`, `core/src/config/storage.ts:160-241`,
`core/src/services/gitWorktreeService.ts:419`,
`core/src/agents/arena/ArenaManager.ts:128-140`,
`cli/src/ui/voice/voice-availability.ts:50`, and the worktree `git rev-parse`
layout of this checkout.
- Codex CLI (local clone, highest tag `rust-v0.153.4`):
`codex-rs/linux-sandbox/src/{bwrap,landlock}.rs`,
`codex-rs/sandboxing/src/seatbelt_base_policy.sbpl` (`(deny default)`),
`codex-rs/Cargo.toml` (`seccompiler = "0.5.0"`, `landlock = "0.4.4"`),
`codex-rs/vendor/bubblewrap/`,
`codex-rs/protocol/src/protocol.rs:983-1023` (`SandboxPolicy`),
`codex-rs/cli/src/main.rs:145` (`codex sandbox` subcommand).
- dsh (`deepseek-ai/deepseek-harness`, MIT; `native/system` BSD-3-Clause):
`native/system/packages/entry/src/main.c` (299 lines),
`native/system/docs/cli-contract.md`,
`packages/sandbox/sandbox-local/src/profiles.ts` (bwrap argv form),
`packages/sandbox/sandbox/src/index.ts` (the `SandboxEnforcement` union plus
`denialSignatures` and `runnerFailureRules`),
`.agents/notes/implemented/feature/2026-07-06-sandbox.md`
(alternatives considered).
- Claude Code 2.1.266 (binary + CLI evidence): bwrap + `apply-seccomp`
from `@anthropic-ai/sandbox-runtime`; `CLAUDE_CODE_FORCE_SANDBOX`.

View file

@ -0,0 +1,763 @@
# Linux 内核沙箱(bwrap + landlock-run)设计
[English](2026-09-09-linux-kernel-sandbox.md) | [简体中文](2026-09-09-linux-kernel-sandbox.zh-CN.md)
内部设计文档,目标是让 qwen-code 在没有容器运行时的 Linux 主机上获得内核级隔离
——弥补今天的空白:Linux 上的 `--sandbox` 实际含义是"docker/podman,或者什么都
没有",而 `QWEN_SANDBOX=false` 是整个集成测试矩阵的默认值,因此绝大多数 Linux
用户在完全没有 OS 级约束的情况下运行 agent。
各项参照点已于 2026-09-09 对照本仓库源码,以及三个同类 agent(Codex CLI、
Claude Code、DeepSeek Harness)的公开源码逐一核实;见 § 证据。
## Phase 0 —— 本 PR 实现前已核实的基线
本节记录 2026-09-09 调研时的实现前状态。下文 P0 描述已交付的 bwrap 改动;本节的基线陈述不代表当前分支。已修改代码的引用使用文件名与符号,避免沿用失效的行号。
| 事实 | 证据 |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 沙箱是整个 CLI 的重新 exec("hop"),从不是按命令粒度 | `packages/cli/src/llm.tsx` (`main`, sandbox hop) 依次调用 `loadSandboxConfig` → `start_sandbox` → `process.exit(0)` |
| 今天的后端只有 `docker`、`podman`、`sandbox-exec` | `packages/cli/src/config/sandboxConfig.ts` (`VALID_SANDBOX_COMMANDS`) |
| 在 Linux 上,只有显式启用沙箱时容器后端才会成为候选 | `sandboxConfig.ts` (`getSandboxCommand`) —— `sandbox === true` 才会 push docker/podman |
| 仓库中不存在任何 landlock/seccomp/bwrap/unshare 隔离实现 | 对 `packages/**/*.ts` 的 grep —— 只有无关的偶然命中 |
| macOS Seatbelt:6 个内置 `.sb` profile,`(allow default)` + 禁写白名单姿态 | `packages/cli/src/serve/sandbox-macos-*.sb`;`sandbox.ts` (`BUILTIN_SEATBELT_PROFILES`) |
| `start_sandbox` 的 sandbox-exec 分支是任何原地后端的结构模板 | `packages/cli/src/serve/sandbox.ts` (`start_sandbox`, Seatbelt) |
| `SANDBOX` 环境变量标记"已在沙箱内",被 UI/警告/预连接逻辑消费 | `sandboxConfig.ts` (`getSandboxCommand`)、`headlessSafetyWarnings.ts:43`、`systemInfo.ts:141` |
| `SandboxConfig.image` 目前对每个 command 都是必填 | `packages/core/src/config/config.ts` (`SandboxConfig`)、`sandboxConfig.ts` (`loadSandboxConfig`) |
| 更新后重启的环境交接按 `command !== 'sandbox-exec'` 分支(容器 vs 非容器) | `packages/cli/src/llm.tsx` (`main`, sandbox handoff) |
| vendor 平台二进制直接提交进 git,并通过 `files: [dist, vendor, ...]` 分发 | `packages/core/vendor/ripgrep/<arch>-<platform>/rg`、`git ls-files packages/core/vendor/` |
| 集成测试矩阵的默认值是 `QWEN_SANDBOX=false` | 根 `package.json:57,61,64`(`test:integration:*:sandbox:none`) |
对照项事实(对照 Codex CLI 源码、dsh 源码与 Claude Code 二进制证据核实;细节见
§ 证据):
- Codex:`seccompiler` + `landlock` crate + vendored bubblewrap;Seatbelt
`.sbpl` 采用 `(deny default)`;两个原生 Windows 沙箱后端;`codex sandbox`
一等子命令;`SandboxPolicy` 各变体自带 `network_access`。
- dsh:优先 bwrap,回退到自写的 299 行 C11 `landlock-run`(musl 静态链接、
先自我约束再 exec、fail-closed、报告 `full`/`partial` 强制等级、启动器失败
退出码 125 的契约);以平台可选 npm 包形式分发。
- Claude Code:Linux 上在无容器运行时的情况下使用 bwrap + seccomp;
`CLAUDE_CODE_FORCE_SANDBOX`;`--no-sandbox` 本身被其自动模式规则挡住。
## 目标 / 非目标
**目标**
1. 一种 Linux 约束方案,**不需要 root、不需要守护进程、不需要镜像、不需要在
消费端主机上有编译器**——可用于 rootless CI、共享构建机与精简容器。
2. 接入既有的 hop 架构(`loadSandboxConfig` → `start_sandbox`),并提供与
macOS Seatbelt 路径已暴露的可写根语义一致的语义。
3. 凡显式请求过沙箱的地方一律 fail-closed;凡未显式请求的地方诚实报告强制
等级(`full` / `partial` / `none`)。
4. 以可审查的增量交付:先显式启用,最后才翻默认值。
**非目标**
- 不引入 Starlark 式策略语言(既有的 tree-sitter AST + 规则层是命令决策平面,
本设计是隔离平面)。
- 不内置 MITM 网络代理(Codex 那个五位数行数的 network-proxy)。既有的
`QWEN_SANDBOX_PROXY_COMMAND` 钩子仍然是走代理的方案。
- 不做 Windows 原生沙箱(那是独立且更大的投入;真要做时以 dsh 的
ACL/受限令牌设计为参照)。
- v1 不做按命令粒度的约束(见 D2)。
- 不改动 macOS Seatbelt profile,也不改动容器后端。
## 设计决策
### D1 —— 两个 Linux 后端:优先系统 `bwrap`,vendored `landlock-run` 作为回退
**决策。** 新增两个 sandbox command:
- `bwrap` —— 使用主机上的 bubblewrap(各主流发行版仓库里都有)。探测是
**功能性**的:真的跑一次最小约束下的 `true`,而不是 `--version`(在
`kernel.unprivileged_userns_clone=0` 或某个 LSM 拒绝 `mount` 的机器上,
装了 bwrap 一样会失败)。
- `landlock` —— 使用 vendored 的 `qwen-landlock-run` 辅助程序:约 300 行 C11,
直接面向 Landlock 原始 UAPI,用 musl 静态链接,按架构提交在
`packages/core/vendor/landlock-run/` 下。Landlock 是一个独立的 syscall 家族,
既不需要 user namespace 也不需要 mount 权限,因此恰好能在 bwrap 不能用的
地方工作。
**为何是这个形态。** Node 无法像 Codex 的 Rust 那样链接 seccomp/Landlock 库;
约束必须由我们 spawn 出来的可执行文件施加。dsh 已经证明"回退辅助程序"这一模式
成本很低(299 行、单文件、除 musl 外零依赖库),且无需在消费端主机上编译即可
分发。
**为何 bwrap 优先。** 不需要新增任何要分发的字节,而且 `--ro-bind / /` 用一个
flag 就给出一个只读的主机根,再按目录覆盖出可写点。它还能 unshare PID 与
network 命名空间;这里只用到 network 那一个——D6 解释为什么不动 PID 命名空间。
### D2 —— hop 粒度(与 Seatbelt 分支一致),而不是按命令粒度
**决策。** 新后端在 `start_sandbox` 中包裹整个 CLI 的重新 exec,与今天的
`sandbox-exec` 完全一样。按命令粒度的约束(像 Codex/dsh 那样包裹每一次
`run_shell_command` spawn)明确延后。
**理由。** hop 只需要动 `sandboxConfig.ts`、`sandbox.ts`、`config.ts`(类型)、
`llm.tsx`(交接分支)与 `systemInfo.ts`。按命令粒度的约束则需要一条贯穿 core
工具调度器的策略载体、按调用的网络决策(LLM 客户端必须保持不受约束——这正是
我们的 Seatbelt `proxied` profile 存在的原因),以及一整套拒绝-升级的交互。
那是另一份设计;hop 用约 10% 的手术量拿到约 90% 的保护(agent 执行的命令无法
逃出工作区)。
**已接受的代价 —— 拒绝的可见性。** hop 约束的是整个进程,因此一次被拒的写入
到达模型时,只是 `run_shell_command` 抛出的一个裸 `EROFS`/`EACCES`,没有任何
东西标明它是一次**策略**拒绝。dsh 记录过由此产生的失效模式:"一次没有升级路径
的拒绝是终局性的——模型只能放弃,而这会把操作者逼到全局配置
`danger-full-access`,从而彻底废掉沙箱。"拒绝标记加一次性升级,恰恰是按命令
粒度约束才能买到的东西,所以 v1 只做缓解而非解决——而这个缓解是对既有机制的
一次改动:
`core/src/core/prompts.ts` (`getCoreSystemPrompt`) 已经按 `process.env['SANDBOX']` 给系统提示
分支:`'sandbox-exec'` 给出 "# macOS Seatbelt" 段落,其他任何非空值给出
"# Sandbox" 段落,未设置则给出 "# Outside of Sandbox"。两个后果:
- 不改的话,`SANDBOX=bwrap` 会落进通用分支,模型被告知自己"运行在一个沙箱
**容器**里"——这是错的,而且那段文字点的是 `Operation not permitted`,而
bwrap 的拒绝读作 `Read-only file system`。
- 加一个小分支后,边界就变得准确且自描述:点明后端与可写根边界,区分只读挂载
拒绝(`EROFS`)与普通权限错误(`EACCES`),并指示模型上报约束拒绝,而不是绕道解决。
该分支是 P0 的工作项(见 § Phase P0),不是后续跟进项。
### D3 —— vendored 二进制提交进 git,并配一道可复现性 CI 关卡
**决策。** `qwen-landlock-run` 二进制位于
`packages/core/vendor/landlock-run/{x64,arm64}-linux/qwen-landlock-run`,提交进
git——沿用既有的 ripgrep/tree-sitter 先例(`git ls-files packages/core/vendor/`)。
C 源码就放在旁边 `packages/core/vendor/landlock-run/src/qwen-landlock-run.c`,
配 `scripts/build_landlock_run.mjs`(`musl-gcc -static -O2`)。一个 CI job 在
两种架构上从源码重建,若与已提交的字节不一致则失败。
**考虑过的替代方案。**
- _平台可选 npm 包(dsh 的模式)_ —— 供应链更干净,但要多养一条发布流水线;
qwen-code 已经为 ripgrep 付了"提交二进制"的成本,所以一套机制覆盖两者。
如果 vendored 集合继续变大,再重新评估。
- _安装时编译_ —— 否决(dsh 的原话:"一个只在恰好装了编译器的地方才存在的
回退,不算回退";而且我们的 `postinstall` 已经为 ripgrep 承担关键职责,
必须保持快速)。
- _提交二进制但不加重建关卡_ —— 否决:无法验证的 blob 就是无法审查的 diff;
重建并比对的 CI job 是让 ripgrep 式分发保持诚实的折中。
### D4 —— 强制等级的诚实性:`full` / `partial` / `none` 只报告,从不承诺
**决策。**
- 每个后端探测都报告一个强制等级:bwrap ⇒ 对所选挂载策略为 `full`
(功能性探测验证挂载建立,不验证与全部主机能力隔离);landlock ⇒ 由内核 ABI 协商决定
`full` 或 `partial`(旧 ABI 无法治理新的访问位——例如 ABI 3 之前的
`truncate`);容器 ⇒ `full`(其边界是运行时自己的问题)。
- 显式请求的沙箱(`--sandbox`/`QWEN_SANDBOX` 指名某个 command,或 `=true`)
若探测失败 ⇒ `FatalSandboxError`(既有行为,保留)。**对显式请求而言,
静默地无约束通过永远不合法。**
- 隐式缺席(Linux,未请求沙箱)⇒ 无约束运行。不为此新增任何 UI 面:
`getSandboxEnv()` 已经会回答 `no sandbox`(`systemInfo.ts:143-148`),而
`headlessSafetyWarnings.ts` 已经覆盖了 headless-yolo 的情形。
- 等级通过 `SANDBOX_ENFORCEMENT` 传入被沙箱化的子进程,以便 UI 渲染它
(例如 `landlock (partial, kernel ABI 3)`)。
### D5 —— 网络策略:三种模式映射到既有的代理钩子上
**决策。** `open`(共享网络)/ `closed`(`--unshare-net`)/ `proxied`(共享网络
并向主机侧 `QWEN_SANDBOX_PROXY_COMMAND` 注入 `HTTP(S)_PROXY`)。解析顺序:
`QWEN_SANDBOX_NET` 非空且不是 `closed`/`open`/`proxied` 之一时,启动即拒绝
(`Invalid QWEN_SANDBOX_NET …`)——硬拒绝开关上的拼写错误绝不能静默落到
最宽松的模式。否则:
1. `QWEN_SANDBOX_NET=closed` ⇒ closed(硬拒绝,优先于代理配置)。
2. `QWEN_SANDBOX_NET=proxied` 但未提供非空白的 `QWEN_SANDBOX_PROXY_COMMAND` ⇒ 启动报错。
3. 否则设置了 `QWEN_SANDBOX_PROXY_COMMAND` ⇒ proxied。
4. 否则 ⇒ open。
这与 Seatbelt 的 profile 矩阵(open/closed/proxied)一致,且 v1 不引入新的
profile 词汇表。
`closed` 是整个网络命名空间的切断,而不是出口过滤:它同时移除 loopback,
于是 IDE companion(通过 `~/.qwen/ide` 锁文件里的端口发现,
`core/src/ide/ide-client.ts:644-678`)、主机侧监听 `:4170` 的 `qwen serve`,
以及任何 localhost MCP server 都会变得不可达。抽象命名空间的 unix socket 也是
按网络命名空间隔离的,因此解析到抽象 socket 的 X11 连接会随之失效。所以
`closed` 只对单次一发的受约束运行是正确的;§ 已核实的兼容性影响 表把这一点
记录下来,而不是藏在一个模式名后面。
### D6 —— 不用 PID 命名空间,因为 PID 存活性是关键依赖
**决策。** bwrap profile **不** unshare PID 命名空间,也不新增任何开关来打开它。
`--proc /proc` 同样不出现——但原因并不是本文档某个早期草稿给出的那个。那份草稿
声称从 user namespace 内部挂载一个新的 procfs 需要持有 PID 命名空间;在内核
7.0.0 + bwrap 0.11.1 上实测,**不带** `--unshare-pid` 的 `--proc /proc` 可以正常
挂载,所以那个说法是错的。省掉这个 flag 的真实原因是它没必要、而且略微更松:
递归的 `--ro-bind / /` 已经把主机 `/proc` 以只读方式带进来了——实测有 133 个可见
的数字条目,这已是 Node 所需的全部——而一个新的 procfs 实例会以可读写挂载。
**理由。** qwen-code 通过 PID 仲裁跨进程所有权。以下只是示例,并非读取方的完整清单:
- `cli/src/serve/conversations/conversation-runtime-ownership.ts:44-65` —— owner
记录带 `pid`,而 `processIsAlive()` 把任何非 `ESRCH` 的结果都视为存活;
- `cli/src/serve/live/discovery.ts:147` —— 同一个谓词;
- `core/src/services/worktreeSessionService.ts:424-446` —— 同一个谓词,外加一个
`status.hostname !== os.hostname()` 守卫,对另一台机器写下的记录保守地回答
`active`。
其他读取方还包括 `packages/core/src/services/session-writer-lease.ts`(`isProcessAlive`)与 `packages/qwen-live/src/host/discovery.ts`(`processIsAlive`)。未来引入 PID 命名空间时,必须覆盖共享所有权记录的所有写入方和读取方,包括本清单之后新增的读取方。
这些所有权记录可能通过可写的 Qwen 状态目录跨越约束边界共享。
在私有 PID 命名空间内,受约束 CLI 自己的 PID 是命名空间局部的(1、2、3……)。
把这个数字写进共享状态不只是没用,而是**主动错误**:主机 PID 2 是 `kthreadd`,
属 root,因此主机侧的 `kill(2, 0)` 返回 `EPERM`,而上面每一个谓词都会把它读成
_存活_。结果是一个永远不会显示为已死的 owner,于是交接与回收永远不触发——这是
静默挂起,而不是可见的失败。隔离不值得拿守护进程赖以为生的一条正确性不变式去
换,而文件系统边界才是本设计真正要做的事。
**已接受的代价。** 受约束进程能看到并向主机进程发信号,procfs 魔法链接
(`/proc/<pid>/root/…`)仍然是命名主机路径的一种方式。这条路径并不是只读的:
内核会按*目标*任务的根挂载重新打开它,于是对一个同 uid 的主机进程而言,它到达
的是主机的可写根,受 `ptrace_may_access` 的 READ 检查约束,而不是本命名空间的
只读 bind。Yama 的 ATTACH 限制不能证明这些链接受到保护(见 § 安全考量)。在 Landlock 后端上,ruleset 同样治理被
重新打开的那个路径。
**将来若要加它的前置条件。** owner 记录必须先携带命名空间身份,且读取方必须把
"记录于另一个 PID 命名空间"视为*未知*,而绝不是存活——这是仲裁谓词的 fail-closed
方向。`worktreeSessionService` 的 hostname 守卫就是要照抄的形状
(`/proc/self/ns/pid` 的 inode 是标准句柄)。在那之前,一个 PID 命名空间开关就是
一把没有调用者的自伤枪,所以本设计干脆不提供任何开关。
### D7 —— 选择顺序与推进节奏
**决策。** 指名 command 永远是显式的:`QWEN_SANDBOX=bwrap` / `landlock` /
`docker` / `podman` / `sandbox-exec`。对于 `--sandbox`/`QWEN_SANDBOX=true`
(未指名),候选顺序在 P0–P2 保持不变(Linux:只有 `docker`、`podman`——对现有
用户零行为变化)。**P3** 把未指名情况下的 Linux 候选顺序翻成
`bwrap` → `landlock` → `docker` → `podman`,并(在自己的 PR + 兼容性数据支撑下)
让 Linux 沙箱像 macOS 上的 `sandbox-exec` 那样被自动检测
(`sandboxConfig.ts` (`getSandboxCommand`)),以 `QWEN_SANDBOX=false` 作为逃生舱——这是既有的
写法(`getSandboxCommand()` 在 `sandboxConfig.ts` (`getSandboxCommand`) 接受 `0` / `false` /
空值;像 `off` 这种臆造的值会被当成 command 名而被拒绝)。
**理由。** 在显式 `true` 下优先选内核后端,会改变 `--sandbox` 对 docker 用户的
含义;在 P0 里静默这么做就是一次行为破坏。等后端被证明可靠之后,这次翻转很廉价。
## Phase P0 —— `bwrap` 后端(纯 TypeScript)
### 配置面
- `packages/core/src/config/config.ts`:把 `SandboxConfig.command` 放宽以纳入
`'bwrap'`(`'landlock'` 在 P1 随其辅助程序加入),并把 `image?: string` 改为
可选——它只是按约定对容器 command 必填,而 `loadSandboxConfig` 已经在容器
command 没有 image 时返回空配置来强制这一点(行为不变)。
- `packages/cli/src/config/sandboxConfig.ts`:
- `VALID_SANDBOX_COMMANDS` += `'bwrap'`。
- 把 `runSandboxProbe` 泛化为按 command 的探测 argv:`docker`/`podman` →
`['version']`(不变);`sandbox-exec` → 跳过(不变);`bwrap` → 功能性探测
`['--ro-bind','/','/','--dev','/dev','--die-with-parent','--','true']`
——与 profile 使用的命名空间相关 flag 相同,因此探测通过就意味着 profile 的
命名空间设置会成功(D6:profile 不带 `--unshare-pid`/`--proc`)。该探测刻意
省略 `--bind` 与 `--chdir`,因为它们取决于解析出的根,而不取决于主机能力。
- 候选列表:P0 不变(只走指名 command 的路径)。
### `start_sandbox` 的 bwrap 分支
在 `packages/cli/src/serve/sandbox.ts` 中新增分支,结构上镜像 seatbelt 分支
(`sandbox.ts` (`start_sandbox`, Seatbelt)):
1. **可写根** —— 与 Seatbelt permissive profile 授予的集合相同,每一个都经过
`realpathSync`(内核比较的是解析后的路径;seatbelt 分支出于同一原因已经做了
规范化,`sandbox.ts` (`start_sandbox`, `TARGET_DIR`)):
| 根 | 值 |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `TARGET_DIR` | `realpathSync(process.cwd())` |
| `TMP_DIR` | `realpathSync(os.tmpdir())` —— 以可写方式绑定;`/tmp` 从不被换成新的 tmpfs(见下) |
| `CACHE_DIR` | `XDG_CACHE_HOME`,为空或未设置则用 `~/.cache`(只创建末级目录,然后 realpath) |
| `QWEN_DIR` | `Storage.getGlobalQwenDir()`(mkdir -p,realpath) |
| `RUNTIME_DIR` | `Storage.getRuntimeBaseDir()`(mkdir -p,realpath) |
| git 目录 | `git rev-parse --git-dir` 与 `--git-common-dir`,当它们解析到 `TARGET_DIR` 之外时 |
| npm 缓存 | `~/.npm`(若存在) |
| `INCLUDE_DIR_n` | `workspaceContext.getDirectories()` 去掉 `TARGET_DIR`(无 5 个上限;argv 没有 profile 参数数量限制;解析到 home 目录内部的条目会被拒绝) |
**为什么需要 git 那两个根。** 在 worktree 检出里 `.git` 是一个指向别处的文件,
因此 index、`HEAD`、reflog 和 objects 全都位于工作区之外。对照本仓库自身的
worktree 布局核实:
```text
toplevel (cwd): /…/.qoder/worktree/qwen-code/o2qocx
--git-dir: /…/Projects/qwen-code/.git/worktrees/o2qocx
--git-common-dir: /…/Projects/qwen-code/.git
```
没有这两个根,worktree 工作区里的每一次 `git add` / `commit` / `stash` 都会
以 `EROFS` 失败,并把 `enter_worktree`、Arena 和 `/review` 一起拖下水——而
worktree 是 qwen-code 的一等工作流,不是边缘情况。解析在 hop 时刻只做一次;
非仓库的 cwd 什么也解析不出,也就不贡献任何根。注意这里顺带修补的不对称:
六个 Seatbelt profile 同样都没有授予 git common dir,所以 macOS 今天有同一个
洞。修 Seatbelt 不在范围内(§ 非目标),但这个缺口现在被记录下来了。
**为什么 `/tmp` 是绑定而非 tmpfs。** 一个新的 `--tmpfs /tmp` 会与上表的
`TMP_DIR` 行自相矛盾,并遮蔽主机放在那里的路径:`/tmp/.X11-unix`(无法启动
GUI)与 `/tmp/ssh-*/agent.*`(`git push` 无法用 ssh-agent 认证)。既然
`os.tmpdir()` 本来就是可写根、而主机 `/tmp` 本来就是全局可写,私有 tmpfs
买到的很少却要付两个可见功能的代价,所以它既不作为默认值提供,也不作为开关
提供。
容器分支以只读方式挂载的那些路径—— `~/.config/gcloud` 与
`GOOGLE_APPLICATION_CREDENTIALS` 指向的文件(`sandbox.ts` (`start_sandbox`, `GOOGLE_APPLICATION_CREDENTIALS`))——在这里
不需要对应物:`--ro-bind / /` 已经让它们可读,而它们在容器里本来也从不可写。
Seatbelt profile 还额外授予 `/dev/stdout`、`/dev/stderr`、`/dev/null`、
`/dev/ptmx`、`/dev/ttys*`(`sandbox-macos-permissive-open.sb:23-27`);bwrap 的
`--dev /dev` 覆盖了这一集合(null/zero/full/random/urandom、tty、pts/ptmx,
以及 std\* 符号链接)。它同时遮蔽 `/dev` 下的其他一切,包括 `/dev/snd`——因此
受约束时语音输入不可用(§ 已核实的兼容性影响)。
不存在的根会被跳过(bind 源缺失会让 bwrap 失败);已被更早的根覆盖的根会被
丢弃。
2. **argv 模板**(dsh 在 `packages/sandbox/sandbox-local/src/profiles.ts` 中使用
的形状):
```text
bwrap
--ro-bind / / # 递归:/proc /sys /run 一并以只读带入
--dev /dev
--die-with-parent
[--unshare-net] # 仅 closed 模式
--bind <root> <root> # 每个可写根一对
--chdir <TARGET_DIR>
-- <cliArgs...> # process.argv,含 llm.tsx 已经做过的
# stdin / session-id 注入
```
3. **子进程环境** —— 挂在 `spawn` 的 env 上,而不是通过 `bwrap --setenv`,这样
一条代码路径服务两个后端(landlock 辅助程序以启动器的环境原样 exec),并且
不依赖某个特定 bwrap 版本是否有某个 flag:
- `SANDBOX=bwrap`(P1 起也可能是 `qwen-landlock-run`)与
`SANDBOX_ENFORCEMENT`。
- `NODE_OPTIONS` 按 seatbelt 分支完全相同的方式合并。
- proxied 模式下的代理变量。
- **删除**:`DISPLAY`、`WAYLAND_DISPLAY`、`MIR_SOCKET`。
`shouldAttemptBrowserLaunch()`(`core/src/utils/browser.ts:52-60`)在 Linux
上纯粹依据这三者是否存在来决策。若留着它们,沙箱内的一次 OAuth 登录会
`xdg-open` 出一个*作为受约束子进程*的浏览器,而它随后无法写自己的 profile
目录(`~/.config/<browser>` 不是可写根),失败的样子看起来像认证 bug。
删掉它们让既有的"打印 URL"路径变得确定,用户在自己正常的主机浏览器里打开
链接。
4. **代理** —— proxied 模式按 seatbelt 分支的方式启动主机侧代理
(`sandbox.ts` (`start_sandbox`, Seatbelt proxy):detached 方式 spawn、安装会杀掉进程组的
exit/SIGINT/SIGTERM 处理器)。共用的 bwrap 执行器等待配置的代理 URL(默认 `http://localhost:8877`)响应,最多 30 秒,并把
`HTTP(S)_PROXY` 注入**到 spawn 的 env 上**。
驱动这个生命周期的两个键——`QWEN_SANDBOX_PROXY_COMMAND` 与
`QWEN_SANDBOX_NET`——都是约束决策,因此位于
`PROJECT_ENV_HARDCODED_EXCLUSIONS` 之中:代理命令在宿主机上、约束之外、
agent 启动之前经 `bash -c` 执行,只能来自操作者的启动环境或 home 作用域的
`.env`,绝不能来自仓库内容。
seatbelt 那段代码刻意*没有*被原样复用。它构造一个 `sandboxEnv` 对象、把代理
变量写进去(`sandbox.ts` (`start_sandbox`, Seatbelt `sandboxEnv`)),然后从不把它传给 `spawn`——那次调用用的是
`{ ...process.env, ...childEnv }`(`start_sandbox`)。因此在 macOS 上代理变量被
算出来又被丢掉,于是 `QWEN_SANDBOX_PROXY_COMMAND` 启动了一个受约束进程根本
不知道其存在的代理。`noUnusedLocals` 抓不到它,因为下标赋值算作一次使用。
那是既有缺陷,不是本阶段引入的,而修它会改变 macOS 行为——不在本处范围内
(§ 非目标:不改动 Seatbelt 路径),改为记录在 § 待决问题 中。bwrap 分支与 sandbox 子命令共享 `runBwrap` 和 `buildBwrapEnv`,通过子进程环境传递代理变量。与 Seatbelt 共享执行器仍留待其环境变量缺陷修复之后。
5. **spawn 契约** —— `stdio: 'inherit'`,spawn 前 `process.stdin.pause()` / close 时 `resume()`,以子进程退出码 resolve。异步执行器使用 POSIX `detached: true` 创建不持有宿主控制终端的会话;同步验证探针传入 bwrap `--new-session`。异步执行器把 SIGINT/SIGTERM 转发给沙箱进程组,把信号退出映射为 130/143,并转发 SIGWINCH,让终端尺寸变化仍能到达 TUI。在代理就绪期间取消会停止代理,不会在随后启动载荷。这一会话行为仅用于 bwrap;Seatbelt 路径不变。
### `llm.tsx` 的交接
`llm.tsx` (`main`, sandbox handoff) 按 `sandboxConfig.command !== 'sandbox-exec'` 分支来决定更新后
重启的环境交接(容器 vs 非容器)。把非容器那一侧放宽:`bwrap`/`landlock` 必须走
sandbox-exec 分支(它们是原地 hop,不是镜像——没有
`CUSTOM_SANDBOX_IMAGE_ENV_VAR`)。
### UI
`packages/cli/src/ui/systemInfo.ts` 在 **P0 无需改动**:`getSandboxEnv()` 末尾的
`return sandbox`(`:156`)已经会把未知的 `SANDBOX` 值原样渲染,因此 `bwrap` 本身
就能正确显示。强制等级后缀(`landlock (partial)`)随 P1 落地——P1 才是真正可能
产出 `full` 之外等级的阶段;更早交付这个后缀,等于为一个尚不存在的后端交付一个
不可达分支。P0 仍然在有意义的地方暴露等级:`qwen sandbox` 会从
`SANDBOX_ENFORCEMENT` 打印 `Enforcement: <level>`。
### 模型可见的边界
`packages/core/src/core/prompts.ts` (`getCoreSystemPrompt`):为原地内核后端加一个分支。不加的话
`SANDBOX=bwrap` 会落进通用分支,那会告诉模型它跑"在一个沙箱容器里",并教它去找
`Operation not permitted`——对 bwrap 的拒绝而言这是错的形状。新分支点明后端、
说明可写根之外主机根是只读的、给出 `Read-only file system`(`EROFS`)拒绝,
并区分 `Permission denied`(`EACCES`)也可能是可写根内的普通权限错误。
把约束拒绝上报给用户,而不是绕道解决。理由见 D2。该分支只匹配
`bwrap`;P1 会把 `qwen-landlock-run` 与强制等级措辞一起加进来,原因和 UI 后缀
要等的原因相同。
实测的措辞很重要。worktree 检出里一次被拒的 `git add` 读作
`fatal: Unable to create '…/index.lock': Read-only file system`,所以该分支必须在
正文里点出 `Read-only file system`——只点 `EROFS` 这个符号名并不匹配模型实际
看到的内容。
### `qwen sandbox` 自检子命令
新增 `packages/cli/src/commands/sandbox.ts`(+ 测试),与既有 command 模块并列
注册。之所以拉进 P0 而不是延后:§ 已核实的兼容性影响 里的每一项影响,在任务
中途出错之前都是不可见的,所以后端和证明它能工作的手段必须一起交付——下面的
计划中的 CI 通道与 E2E 表也都要求这一点。本 PR 尚未实现 Linux bwrap CI 通道。
从宿主机上的项目目录运行 `QWEN_SANDBOX=bwrap qwen sandbox`,显式检查该后端。原始会话的一次性环境变量赋值不会保留在宿主 shell 中。报告使用该目录的 settings;会话单独传入的 `--include-directories` 授权可能不同。
- `qwen sandbox` —— 打印解析出的后端、探测结果、强制等级、可写根(含解析出的
git 目录)与网络模式。
- `qwen sandbox <cmd>…` —— 通过解析出的后端跑一条命令并报告结果(对应
`codex sandbox`)。
- `qwen sandbox --verify` —— 行为电池:工作区外的写入必须失败;工作区内的写入
必须成功;载荷读取的 `/proc/self/ns/pid` 标识必须与宿主一致(D6 的回归守卫);无法读取标识时验证失败;网络检查只查看接口可见性:`closed` 必须仅有 `lo`,`open`/`proxied` 必须存在非 loopback 接口。这不验证互联网可达性、代理可用性或强制经由代理的路由;这些需要单独的集成检查。
### 审查跟进契约
- 在启动 bwrap 前,拒绝规范化后等于 HOME 或其任意祖先(包括 `/`)的可写根。此规则也适用于符号链接和额外工作区目录,而额外工作区目录适用更严格的下限:任何解析到 home 目录内部的条目都会被拒绝,因为工作区范围的 settings(`context.includeDirectories`)是仓库内容,不能让它挑选 `~/.ssh` 或带有 `.git` 的兄弟检出这类 home 内部根。已被内建根覆盖的条目(工作区本身、缓存/运行时目录)仍然保留。应使用范围更小的工作区或缓存路径,不能静默授予整个 HOME 或主机文件系统。
- 使用共享 `gitEnv()` 清理器派生 Git 根,防止环境中的仓库选择变量重定向探测。仅真实 `.git` 目录,或在 common 仓库下登记且反向指针匹配的 linked worktree,才能自动获得 Git 授权。符号链接元数据、伪造 gitfile、独立 Git 目录及没有该登记的 submodule gitfile 均不贡献根;用户必须显式添加所需的外部元数据目录。合法 linked worktree 继续保留整个 common Git 目录授权。如果仓库根为 HOME、工作区是其子目录,也继续授予该仓库的 `.git`。这些布局有显式测试;检查报告与模型提示明确说明获授的 config/hooks 能影响之后未受约束的 Git 命令。这是保留的权限策略,并非缩小后的元数据白名单。
- 检查命令传递显式 sandbox/image 参数;bare 模式跳过 settings 和 `.env` 加载,safe 模式忽略 settings。纯检查在无后端时可以成功退出;无法实际执行的验证或命令请求返回非零,包括已经处于沙箱内的情况。带自身参数的命令必须放在 `--` 后;之前的未知参数在解析时失败。
- 透传命令直接继承 stdin、stdout、stderr。此模式的检查文本写入 stderr,从而保留大输出及管道中的结构化输出。验证电池继续捕获输出供各项断言使用。
- 实际命令执行与正常 bwrap 启动共享执行器及子进程环境:设置运行时标记、移除 display 变量;proxied 模式启动配置的代理、统一代理环境变量、等待就绪,并在完成或失败时停止代理。代理启动或就绪检查失败时不启动命令;命令启动后代理退出会终止命令并报告失败。纯检查不启动这两个进程;捕获输出的验证使用相同环境策略并设置 `LC_ALL=C`,其中的接口检查不启动或验证代理。
- bwrap 再执行将 Node 启动参数追加到继承参数后,保留子进程环境的优先级,并在 managed 启动标记要求时恢复 Electron 的 Node 模式。
- `full` 描述文件系统挂载约束,不代表与主机服务隔离。检查报告与提示词说明:即使 closed 网络模式下主机 Unix socket 仍可访问,proxied 模式也不强制所有连接经过代理。可写 Git 配置/hooks 和 Qwen settings 能影响后续无约束启动;这一剩余能力随既有授权保留。收窄仓库元数据权限仍需独立决策;bwrap 默认不再授予全局 `~/.gitconfig` 写权限。
- 回归验收:拒绝过宽根及其符号链接;环境 Git 选择变量和伪造 gitfile 无法授权无关仓库;未实际执行的请求返回非零;命令参数要么完整保留、要么被拒绝;透传输出不被捕获;继承的 Node 参数与 managed Electron 模式到达子进程。Linux 挂载约束及 procfs 边缘场景需要 Linux 主机,不能用 mock spawn 测试宣称已验证。
## Phase P1 —— `qwen-landlock-run` vendored 回退
### 辅助程序(新源码,约 300 行 C11)
`packages/core/vendor/landlock-run/src/qwen-landlock-run.c`。设计参照 dsh 的
`native/system/packages/entry/src/main.c`(BSD-3-Clause;是重新实现,不是拷贝):
- CLI 契约(钉在文件头,并从 TS 侧做单元测试):
```
qwen-landlock-run [--ro <path>]... [--rw <path>]... -- <argv>...
qwen-landlock-run --probe
```
`--ro` 授予该路径之下的读 + 执行;`--rw` 授予协商出的内核 ABI 所治理的全部
文件系统访问。其他一切被拒绝(Landlock ruleset 是允许列表)。
- **Fail-closed**:`no_new_privs` → `landlock_create_ruleset`(从已知最高 ABI
向下协商)→ 每个授权一次 `add_rule` → `landlock_restrict_self` → `execvp`。
任何一步失败都以 **125** 退出,stderr 带 `qwen-landlock-run: ` 前缀,并且不
exec。一个打不开的授权根是启动失败,绝不是被静默收窄的 profile。
- **功能性探测**:`--probe` 对自身安装一个最大化 ruleset,并且只打印一行——
`landlock: fully enforced` 或 `landlock: partially enforced (older ABI)`——
退出 0;当内核无法强制(ENOSYS/EOPNOTSUPP)时退出 125。`--version` 式的检查
会漏掉那些有 syscall 但拒绝强制的内核。
- **部分强制**:在较旧 ABI 上,向 stderr 打印
`qwen-landlock-run: partial enforcement (older Landlock ABI)` 并继续——对内核
所治理的一切仍然是受约束的。
- UAPI 结构体/常量在本地定义(内核用户态 ABI 是稳定的;这让构建不依赖工具链头
文件的新旧,同时这些定义本身就充当审计记录)。
### 构建与分发
- `scripts/build_landlock_run.mjs` 一次构建一种架构:
```sh
musl-gcc -static -O2 \
-o packages/core/vendor/landlock-run/<arch>-linux/qwen-landlock-run \
packages/core/vendor/landlock-run/src/qwen-landlock-run.c
```
工具链要求记录在旁(Linux 上 `apt install musl-tools`,macOS 上
`brew install musl-cross`)。
- CI(`verify-landlock-run.yml`,ubuntu x64 + arm64 runner):从源码重建、用
`sha256sum` 与已提交的二进制比对、跑功能性探测、跑行为电池(拒绝 `--rw` 之外
的写入、允许其内部的写入、在支持 Landlock 的内核上校验 `--probe` 的退出码)。
- `packages/core/package.json` 的 `files` 已包含 `vendor/` —— 无需改发布配置。
### TS 集成
- 注册:`SandboxConfig.command` 增加 `'landlock'`;`VALID_SANDBOX_COMMANDS` +=
`'landlock'`;`runSandboxProbe` 增加 `landlock` → `[launcherPath(), '--probe']`
这一支(辅助程序缺失或内核不强制都算探测失败,因此指名 `landlock` 的请求会以
既有的 `FatalSandboxError` 措辞 fail-closed)。
- `packages/cli/src/utils/landlockRun.ts`(新增):`launcherPath()`(通过
`resolveBundleDir` 解析架构,镜像 `getBuiltinRipgrep` 在
`packages/core/src/utils/ripgrepUtils.ts:97-111` 中的遍历规则)、
`probe(): Promise<'full' | 'partial' | 'unusable'>`,以及
`grantArgs(roots: {ro: string[]; rw: string[]}): string[]`。
- `start_sandbox` 的 `landlock` 分支:argv =
`[launcherPath(), ...grantArgs({ro: ['/'], rw: writableRoots}), '--', ...cliArgs]`,
环境继承不使用 `--setenv`(辅助程序以启动器的环境原样 exec;在 spawn 前于
`process.env` 中为子进程设置 `SANDBOX=qwen-landlock-run` 与
`SANDBOX_ENFORCEMENT`,与 seatbelt 分支的 env 合并方式相同)。
- 强制等级的管道:探测结果被串进子进程环境,以便 `systemInfo.ts` 能渲染
`landlock (partial, kernel ABI 3)`。
## Phase P2 —— 在辅助程序中收紧 seccomp
**辅助程序中的 seccomp**(+约 80 行 C):在 `no_new_privs` 之后安装一个最小 BPF
过滤器,拒绝 `ptrace`、`process_vm_readv`、`process_vm_writev`、`mount`、
`umount2`、`init_module`、`finit_module`、
`delete_module`、`kexec_load`、`kexec_file_load`、`bpf`、`perf_event_open`、
`keyctl`、`iopl`、`ioperm`。这是把 Claude Code 的 `apply-seccomp` 模式折进同一个
辅助程序;它只作用于 landlock 路径。bwrap 自身除了 `--seccomp <fd>` 之外没有
seccomp 钩子,而用那个就意味着要分发一个编译好的 BPF 程序去喂它——不在本处范围。
由于辅助程序获得了它此前没有的拒绝能力,本阶段是对一个已发布的指名后端做行为
收紧,而不是纯增量:它需要一条发布说明,并为每一类被拒 syscall 加一个
`--verify` 用例。
## Phase P3 —— 翻默认值(独立 PR,以兼容性数据为前提)
1. Linux 上未指名的 `--sandbox`/`QWEN_SANDBOX=true`:候选顺序变为
`bwrap` → `landlock` → `docker` → `podman`。
2. Linux 上自动检测沙箱(镜像 macOS 在 `sandboxConfig.ts` (`getSandboxCommand`) 的
`sandbox-exec` 自动候选),前提是评审 § 已核实的兼容性影响 并有现场数据支撑
——不只是机制清单,还要有真实工作流命中每一行的频率。
`QWEN_SANDBOX=false` 是文档化的逃生舱。
3. CI:新增 `test:integration:sandbox:bwrap` 与
`test:integration:sandbox:landlock` 通道;保留 `sandbox:none` 通道(无约束
路径仍受支持),但它不再是唯一被演练的 Linux 配置。
## 已核实的兼容性影响
每一行都是对照本仓库核实过的机制,而不是猜测,且只适用于两个原地后端——容器
后端与 macOS 不受影响。这张表是 P3 翻默认值时必须回答的;在那之前,它们是一个
显式启用 flag 的既有后果。
| 领域 | 影响 | 机制 | v1 中的处理 |
| -------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| worktree 检出里的 git | 会完全不可用 | git dir 与 common dir 解析到 cwd 之外(布局已在 § Phase P0 核实) | **已修** —— 两者都是可写根 |
| 跨进程 owner 存活性 | 会静默挂起 | `process.kill(pid, 0)` 返回非 `ESRCH` ⇒ 存活,而 PID 通过 `~/.qwen` 共享(`conversation-runtime-ownership.ts:44-65`、`serve/live/discovery.ts:147`、`worktreeSessionService.ts:424-446`) | **已规避** —— 完全不用 PID 命名空间(D6) |
| GUI 启动(OAuth 登录、`artifact` 打开) | 浏览器无法在约束下运行 | `xdg-open` 子进程继承约束,而 `~/.config/<browser>` 不是可写根 | 删除 display 变量 ⇒ 确定地走打印 URL 路径 |
| `git push` 的 ssh-agent 认证 | 不受影响 | agent socket 通常位于 `/run/user/$UID`(经只读主机绑定保持可达);`/tmp/ssh-*` 下的 socket 也可用,因为主机 `/tmp` 保持绑定、没有被 tmpfs 替换 —— 共享 `/tmp` 暴露的面见 § 安全考量 | —— |
| 语音输入 | 不可用 | `--dev /dev` 遮蔽 `/dev/snd`;`voice-availability.ts:50` 还需要 `PULSE_SERVER` | 已记录 —— 改用容器后端或无约束运行 |
| 自更新 | 部分受影响 | 默认受管更新根是 `QWEN_DIR/updates/npm`(Qwen home 遵循 `QWEN_HOME`);暂存目录、版本目录和 active 清单需要可写,宿主启动器随后会执行其中的代码;自定义固定更新根可能位于授权之外,npm-global 更新则写入全局 prefix | 保留默认受管更新授权及其宿主执行风险;安装目标位于可写根之外时从主机侧更新 |
| IDE companion、主机 `qwen serve`、localhost MCP | 仅在 `closed` 下不可达 | `--unshare-net` 移除 loopback(D5) | `open` / `proxied` 下仍可用 |
| 对 `~/.config`、`~/.kube`、`~/.ssh`、`/usr/local` 的写入 | 被拒绝 | 它们不是可写根 —— 这正是沙箱的意义 | 拒绝额外的 home 内部根;home 之外的路径可通过 `--include-directories` 授权 |
| 会话数据、检查点、worktree、Arena、IDE 锁文件、MCP OAuth token、token 账本 | 不受影响 | 全部位于 `QWEN_DIR` / `RUNTIME_DIR` 之下(`storage.ts:193-241`、`gitWorktreeService.ts:419`、`ArenaManager.ts:136`) | —— |
| `docker.sock`、D-Bus、Wayland 及其他主机 unix socket | 在 bwrap 上可达(实测) | 内核的只读文件系统写检查豁免 `S_ISSOCK`(只有 `S_ISREG`/`S_ISDIR`/`S_ISLNK` 会返回 `EROFS`),因此 `--ro-bind / /` 之下的 socket 仍接受 `connect()` | 在 bwrap 上**已核实** —— 位于所有可写根之外的 socket 仍接受 `connect()`;Landlock 后端留作 P1 的 `--verify` 用例 |
| procfs 魔法链接(`/proc/<pid>/root/…`) | 在挂载策略之外 | 访问使用 `PTRACE_MODE_READ_FSCREDS`,不是 Yama 的 ATTACH 进程关系限制 | **保留的限制** —— 不能从 `ptrace_scope=1` 推导保护成立;更强隔离依赖 D6 前置条件 |
额外目录限制同样适用于 CLI 和 settings 条目,因为解析器接收的是没有来源信息的合并路径。内建授权之外的 home 内部条目会阻止启动,而不是把请求的可写目录静默降级。home 之外的额外根即使来自工作区 settings 也仍然可写;P0 不承诺只有操作者才能添加授权。区分显式操作者授权和仓库配置属于独立的配置策略变更。
## 安全考量
- **规范化**:每一个可写根在进入 profile 之前都经过 `realpathSync`;bwrap/Landlock
比较的是解析后的路径。seatbelt 分支出于同一原因做同样的事。
- **procfs 魔法链接**:仍在这份挂载策略之外,因为 profile 与主机共享 PID
命名空间(D6)。链接按目标任务的挂载解析,访问受凭据、dumpability、
capabilities 和 LSM 检查约束。Linux 7.0 对 procfs 链接使用
`PTRACE_MODE_READ_FSCREDS`,Yama 的进程关系限制则作用于
`PTRACE_MODE_ATTACH`。因此,`kernel.yama.ptrace_scope=1` 不能证明这些
链接被阻断。参见 [procfs 访问检查](https://github.com/torvalds/linux/blob/v7.0/fs/proc/base.c#L708)
和 [Yama 访问检查](https://github.com/torvalds/linux/blob/v7.0/security/yama/yama_lsm.c#L349)。
跟随链接不会调用 `ptrace(2)`,仅禁止该系统调用无法关闭此路径。进程内存 API
使用不同的访问模式,必须分别评估。P0 不承诺与主机进程隔离;更强隔离留待
后续结合 D6 的 owner 记录前置条件处理。这些源码检查不等于对所有支持主机的
动态验证。
- **只读文件系统语义比看起来更窄**:内核只对 `S_ISREG` / `S_ISDIR` / `S_ISLNK`
的写访问返回 `EROFS`。`--ro-bind / /` 之下的 socket、FIFO 与设备节点都被豁免,
因此主机 unix socket 仍然可连接——这是实测结论,不是假设(§ 证据)。这在这里
是刻意的(正是它让 `docker.sock`、D-Bus 与 Wayland 保持可用),但这意味着
"只读的根"绝不能被读作"没有任何 IPC 能出沙箱":任何通过主机 socket 可达的
东西,按构造都在这个边界之外。具体而言,在 `closed` 模式下:裸 TCP connect 以
`ENETUNREACH` 失败,但 DNS 仍能经宿主 `systemd-resolved` 的路径名 socket 解析
——它不是网络命名空间对象,因此 `--unshare-net` 切不断它(在本 PR 的独立验证
主机 Debian 13 上实测)。closed 模式下因此仍留有一条 DNS 形态的旁路。
- **setuid 提权**:在施加约束之前设置 `no_new_privs`(对非特权 Landlock 而言
这也是强制要求)。
- **终端会话**:两个 bwrap 启动点都创建独立的 POSIX 会话,同时保留标准输入输出。这样移除了对宿主控制终端的持有关系,处理了 [bubblewrap 安全文档](https://github.com/containers/bubblewrap#sandbox-security) 中描述的终端会话问题。异步启动器使用 `detached: true`,使 bwrap 及其载荷仍处于同一进程组以转发信号。同步探针使用 bwrap `--new-session`,不使用异步信号转发。这并不增加 PID 或宿主进程隔离。
- **孤儿进程**:bwrap 上用 `--die-with-parent`;Landlock ruleset 是进程作用域的,
随进程消失。
- **解析与 exec 之间的符号链接替换**:通过在 spawn 前紧邻解析来收窄;残余竞态
与 seatbelt 路径已经接受的是同一类(在 `sandbox.ts` 中有记录)。
- **环境**:bwrap/landlock 的 hop 继承完整环境(与 seatbelt 分支对齐)。凭据剥离
仍由既有的 `sanitize-child-env` 消费方负责;本设计不改动那个边界。
- **来自环境变量的约束输入**:可写根推导读取 `XDG_CACHE_HOME` 与(经
`os.tmpdir()`)`TMPDIR`/`TMP`/`TEMP`,网络模式与 proxied 生命周期读取
`QWEN_SANDBOX_NET` / `QWEN_SANDBOX_PROXY_COMMAND`。它们全部位于
`PROJECT_ENV_HARDCODED_EXCLUSIONS` 之中,因此项目 `.env` 或 settings.env 无法
挑选约束根或主机侧代理命令;只有操作者的启动环境或 home 作用域的 `.env` 可以。
proxied 模式会在宿主机上、约束之外、agent 启动之前,用 `bash -c` 执行该命令。
- **非目录授权(P1 辅助程序)**:文件授权只保留与文件兼容的访问位
(内核会对非目录上的仅目录访问返回 EINVAL —— 辅助程序据此做掩码)。
- **可写的 Git 元数据随后会在主机上被执行**:git dir / common dir 授权
让受约束进程可以改写 `hooks/` 与
`config`——`core.fsmonitor`、`core.hooksPath`、`core.pager`、别名、钩子
脚本——而之后**未受约束**的 `git` 调用会以用户身份运行它们。保留该授权
是因为 worktree 提交需要 `objects/`/`refs/`/`logs/` 可写;排除 hooks/config
的收窄仍是后续工作(见 § 审查跟进契约)。bwrap 默认不再授予
`~/.gitconfig` 写权限:只读的宿主根已经提供读取能力。Seatbelt profiles 不变。
- **主机 `/tmp` 是共享的读写授权**:`os.tmpdir()` 保持绑定而不换成 tmpfs,
因此其下的同用户 socket 与锁路径(`/tmp/.X11-unix`、`/tmp/ssh-*/agent.*`)
可以从内部被 unlink 并替换——受约束进程可以顶替应答之后未受约束的
`git push` 签名请求,或截获主机 GUI 的显示 socket。这是刻意保留的:
私有 tmpfs 会破坏 GUI 启动与 ssh-agent 认证(见 § P0 可写根)。不需要
这些能力的操作者可以在启动前把 `TMPDIR` 指到一个按会话的目录。
- **QWEN_DIR 的 `.env` 在约束内被钉为只读**:QWEN_DIR 是可写根(会话、
检查点、worktree、锁文件),但它的 `.env` 是每次后续启动都会重读的、
操作者信任的执行输入——包括 proxied 模式会在主机上经 `bash -c` 执行的
`QWEN_SANDBOX_PROXY_COMMAND`。在可写根之上叠一层只读绑定,受约束进程
就无法经该路径改写主机下一次信任的内容。若文件不存在,根目录解析会先以
独占创建、0600 权限生成空文件,再进行只读绑定;已有内容绝不覆盖。空文件会
留在主机上,包括执行检查命令之后,以便后续解析使用同一路径。非普通文件、
符号链接、多个硬链接或准备文件失败都会终止操作,而不是省略保护。
- **其他 Qwen 状态仍可写,并会被后续宿主运行信任**:`.env` 的覆盖绑定只保护该环境输入路径。用户 `settings.json`(包括 MCP 和 hook 配置)、自定义命令、记忆和受管更新文件仍然可写。配置修改可能影响之后未受约束的启动,受管启动器也会执行从更新根选中的代码。这些是为持久化配置和受管更新保留的能力,不能据此声称所有后续宿主执行输入都已受到保护。收窄它们需要单独设计写入策略和更新交接。
- **运行态标记由启动器提供**:项目和 home 作用域的 `.env` 以及 settings.env
均不能设置 `SANDBOX` 与 `SANDBOX_ENFORCEMENT`,包括重载与运行时快照。
启动器已有的值仍正常继承;通过独立的 `QWEN_SANDBOX` 操作者配置选择后端的
行为不变。
- **启动器/命令失败的归因**:启动器失败以 125 退出并带 `qwen-landlock-run: `
前缀;而一个成功 exec 的子进程也可能以 125 退出,所以消费方必须同时要求
状态码 125 **且**有该前缀 —— 这与 dsh 的 CLI 契约钉下的规则相同。
## 测试计划
以下分别列出已交付的 P0 单元测试与计划中的集成工作。P1 及后续阶段仍作为独立 PR 的计划。
**P0 —— 已交付的单元测试(vitest)**
- `packages/cli/src/config/sandboxConfig.test.ts`:无镜像时显式选择 bwrap、功能探测的 argv 与超时、探测失败与缓存、非法名称,以及 Seatbelt 不需要镜像的路径。
- `packages/cli/src/serve/sandbox-bwrap.test.ts`:bind 的源/目标及顺序、规范化根去重与拒绝、Git 来源与包含关系、已登记 worktree 的反向指针、以 HOME 为根的 dotfiles 仓库、不出现 `--unshare-all` / `--unshare-pid` / `--proc` / `--tmpfs`、网络模式参数,以及正常启动的子进程环境与代理清理。Git fixture 同时隔离环境仓库选择变量和用户/系统 Git 配置;文件系统 fixture 将 HOME、临时存储、工作区彼此分离。
- `packages/core/src/core/prompts.test.ts`:bwrap 文案涵盖 `EROFS`、保留的 Git 元数据能力与主机服务边界;其他后端保持预期文本。
- `packages/cli/src/commands/sandbox.test.ts`:检查输出、最终 positional 与 `--` 命令的顺序、执行失败,以及缺少 loopback 等验证谓词的失败场景。
- `packages/cli/src/commands/sandbox-command-runtime.test.ts`:真实 handler/执行器配合 mock 进程,验证标记、display 移除、继承的 stdio/argv/退出码、代理变量统一/启动/就绪/失败/清理,以及只诊断的检查模式。这些测试不证明 Linux 挂载约束。
**P0 —— 集成跟进,本 PR 未交付**
本 PR 不包含计划中的 `test:integration:sandbox:bwrap` 脚本及在 Ubuntu workflow 中安装 `bubblewrap` 的配置。后续应在真实约束内运行 fake-LLM-server 测试(工作区写入、到 fake server 的连接)。§ 证据中带日期的 Linux 手动观测不能替代该 CI 通道;本地 mock 测试也不增加新的 Linux 约束证据。
**P1 —— 单元测试**
- `landlockRun.test.ts`:探测行 → `full` / `partial` / `unusable` 的映射;
`grantArgs` 的形状;架构解析(mock platform/arch,沿用
`ripgrepUtils.test.ts` 的模式)。
- `sandbox.test.ts`:landlock argv = 启动器 + 授权 + `--` + cliArgs。
- `systemInfo.test.ts`:`landlock (partial, …)` 的渲染,外加守卫 `:156` 原样
透传的无后缀用例。
- `prompts.test.ts`:`SANDBOX=qwen-landlock-run` 选中内核沙箱分支,且 `partial`
措辞只在等级如此时出现。
**P1 —— 构建关卡与集成测试**
- `verify-landlock-run.yml`:从源码重建 → sha256 与已提交二进制比对 → 功能性
探测 → 行为电池,两种架构都跑。
- `test:integration:sandbox:landlock` 通道。
**P2**
- 每一类被拒 syscall 一个 `--verify` 用例,外加一个证明辅助程序对允许的
syscall 仍能成功 exec 的电池用例(否则一个拒绝一切的 seccomp 过滤器也能通过
那些拒绝用例)。
**P3**
- `sandboxConfig.test.ts`:Linux 上未指名的 `--sandbox` / `QWEN_SANDBOX=true`
按新的候选顺序解析;`QWEN_SANDBOX=false` 仍然禁用一切;自动检测在非 Linux 上
不触发。
**E2E(手工,P3 之前每个版本执行)**
| 检查项 | 期望 |
| ---------------------------------------------------- | ---------------------------------------------------------- |
| 在无 docker 的 Linux VM 上 `QWEN_SANDBOX=bwrap qwen` | 能启动;状态行显示 `bwrap` |
| 在 **worktree** 检出内:`git commit` | 可用(git 根的回归守卫) |
| 一个受约束会话持有时再起第二个 CLI | owner 交接仍然触发(D6 的回归守卫) |
| 内部:`touch /usr/local/bin/x` | EROFS |
| 内部:`touch ./x && rm ./x`(工作区) | 可用 |
| 内部:`ls /proc` | 主机进程可见(没有 PID 命名空间 —— D6) |
| `QWEN_SANDBOX_NET=closed`:模型 API 调用 | 快速失败并给出清晰的代理提示 |
| proxied:经 `QWEN_SANDBOX_PROXY_COMMAND` 的 API 调用 | 可用 |
| `kernel.unprivileged_userns_clone=0` 的机器 | 指名 `bwrap` ⇒ "已安装但无法运行";指名 `landlock` ⇒ 可用 |
| 内核 < 5.13(无 Landlock) | `landlock` 探测 ⇒ unusable;显式请求 ⇒ `FatalSandboxError` |
| Ctrl-C / CLI 崩溃 | 没有遗留的受约束孤儿进程 |
| `qwen sandbox --verify` | 电池通过 |
## 推进节奏与兼容性
- P0:任何平台的默认行为都不变;`bwrap` 可按名使用。全部是新增 ——
`VALID_SANDBOX_COMMANDS`、一个 `start_sandbox` 分支、类型放宽、交接分支放宽、
提示词分支,以及 `qwen sandbox` 子命令。
- P1:新增 vendored 二进制(约 70 KB × 2 种架构)与 `landlock` 指名 command;
默认行为不变。
- P2:用 seccomp 收紧 landlock 后端 —— 这是 P3 之前唯一改变一个已发布指名后端
行为的阶段;需要发布说明。
- P3:唯一改变默认行为的阶段;独立 PR、独立设计评审、独立发布说明。
**流程说明。** `packages/cli/src/config/sandboxConfig.ts` 与
`packages/core/src/config/config.ts` 都命中 `packages/*/src/config/**`,因此每个
阶段都触及 AGENTS.md 双层关卡下的核心基础设施。各阶段都不是大范围的
`refactor`,所以都不会被硬阻断,但外部作者在此提交的 PR 按规则(而非按判断)
上报维护者。把工作拆成 P0–P3 也让每个 PR 都留在 CONTRIBUTING.md 的体量阈值之内。
## 待决问题
1. **WSL2**:Landlock ABI 的可用性随 WSL2 内核版本而变;探测能正确处理,但 P3
的自动默认需要把 WSL2 纳入兼容性矩阵。
2. **Snap/Flatpak 约束下的主机**:userns 可能连 root 都被限制;landlock 回退能
覆盖大多数此类情况,但对"两个都不可用"的主机,其错误信息需要同时点出两个
失败原因。
3. **收紧写集合的 profile**:macOS 矩阵里有 `restrictive-*` profile(97 行的
`.sb` 文件);Linux 是否需要在 v1 就有一个对应的更窄根集合,还是可以等 profile
统一那项工作,是一个 P3 的范围界定问题。
4. **按命令粒度的约束**:Codex 级别的按调用策略(自动批准的命令走只读、批准后
走工作区可写)需要 D2 中描述的工具调度器手术。等 P0–P3 的使用数据出来后,
值得单独立一份设计。
5. **Landlock 与 socket 文件**:在 bwrap 后端上,只读根之下的主机 unix socket
是可达的(实测,§ 证据)。一个不授予 socket 所在目录任何访问权的 Landlock
ruleset 是否会阻断 `connect()`,目前仍未确定,而它决定两个后端是否具有相同的
IPC 面 —— 这是一个 P1 的 `--verify` 用例,而不是一个假设。
6. **Seatbelt 的 git 目录缺口**:六个 macOS profile 同样都没有授予 git common
dir,因此在 `--sandbox sandbox-exec` 下,worktree 检出里的 `git commit` 今天
本就应该失败。若能复现,那是一个独立的 bug 修复(一个 profile 参数,不是设计
变更),不应被打包进本项工作。
7. **Seatbelt 的代理环境丢弃**:`sandbox.ts` (`start_sandbox`, Seatbelt `sandboxEnv`) 往一个 `sandboxEnv` 对象里
填了代理变量,而 `start_sandbox` 从不把它传给 `spawn`,因此 macOS 上的
`QWEN_SANDBOX_PROXY_COMMAND` 启动了一个受约束进程看不见的代理。这同样是独立
修复 —— 它改变 macOS 行为、需要自己的回归测试,所以刻意不在此处打包。
8. **`/proc` 下的可选缓存目录创建**:验证 VM 曾报告 Node 递归创建 `/proc/nope/cache` 时挂起。缓存路径现在只执行一次非递归 `mkdir`:不创建缺失的父目录,并丢弃该可选根;正常缺失的 `~/.cache` 末级目录仍会创建。本次跟进在 macOS 上检查了普通缺失父目录与首次创建场景;Linux procfs 场景仍需真实 Linux 验证。必需的 Qwen 与运行时目录创建保持原样。
## 证据
- Linux 上的实机验证,2026-09-10 —— Lima VM,内核 `7.0.0-28-generic`,
bubblewrap 0.11.1,git 2.53.0,`unprivileged_userns_clone=1`,Landlock 出现在
`/sys/kernel/security/lsm` 中。探测脚本曾位于
`.qwen/scripts/verify-bwrap-assumptions.sh`,而 `.gitignore` 按仓库约定排除该
目录,因此它不属于本次改动 —— 它产出的测量结果改为记录在下方,而
`qwen sandbox --verify` 是已提交且有测试的四项检查子集,不能替代手动 Git 与 socket 检查。
该脚本演练了本文档中的十项主张:九项通过,一项失败(D6 中的 `--proc` 主张,
已在上文就地修正)。经测量确认:文档中的探测 argv 能启动;可写根之外的写入
返回 `EROFS`;一个 `--bind` 根是可写的;不带 `--unshare-pid` 时主机 PID 保持
可见(133 个数字 `/proc` 条目);位于所有可写根之外的一个主机 unix socket 仍
接受 `connect()`;只绑定 worktree 时,worktree 检出里的 `git add` 以
`Unable to create '…/index.lock': Read-only file system` 失败,而一旦把 git dir
与 common dir 也绑定进来就成功;`/dev/snd` 在内部被遮蔽而在主机上存在;
`--unshare-net` 阻断出站解析。
- 本仓库:Phase 0 记录 2026-09-09 的实现前调研。已修改代码的引用现使用当前文件/符号;下文保留的行号用于定位历史调研,不代表重新核实了当前分支。D2、D5、D6
与 § 已核实的兼容性影响 背后的影响面排查于 2026-09-10 补充,覆盖
`cli/src/serve/conversations/conversation-runtime-ownership.ts:44-65`、
`cli/src/serve/live/discovery.ts:147`、`core/src/core/prompts.ts` (`getCoreSystemPrompt`)、
`core/src/utils/browser.ts:25-70`、`core/src/utils/secure-browser-launcher.ts:151-181`、
`core/src/ide/ide-client.ts:644-678`、`core/src/config/storage.ts:160-241`、
`core/src/services/gitWorktreeService.ts:419`、
`core/src/agents/arena/ArenaManager.ts:128-140`、
`cli/src/ui/voice/voice-availability.ts:50`,以及本检出的 worktree
`git rev-parse` 布局。
- Codex CLI(本地克隆,最高 tag `rust-v0.153.4`):
`codex-rs/linux-sandbox/src/{bwrap,landlock}.rs`、
`codex-rs/sandboxing/src/seatbelt_base_policy.sbpl`(`(deny default)`)、
`codex-rs/Cargo.toml`(`seccompiler = "0.5.0"`、`landlock = "0.4.4"`)、
`codex-rs/vendor/bubblewrap/`、
`codex-rs/protocol/src/protocol.rs:983-1023`(`SandboxPolicy`)、
`codex-rs/cli/src/main.rs:145`(`codex sandbox` 子命令)。
- dsh(`deepseek-ai/deepseek-harness`,MIT;`native/system` 为 BSD-3-Clause):
`native/system/packages/entry/src/main.c`(299 行)、
`native/system/docs/cli-contract.md`、
`packages/sandbox/sandbox-local/src/profiles.ts`(bwrap argv 形状)、
`packages/sandbox/sandbox/src/index.ts`(`SandboxEnforcement` 联合类型,以及
`denialSignatures` 与 `runnerFailureRules`)、
`.agents/notes/implemented/feature/2026-07-06-sandbox.md`(考虑过的替代方案)。
- Claude Code 2.1.266(二进制 + CLI 证据):来自 `@anthropic-ai/sandbox-runtime`
的 bwrap + `apply-seccomp`;`CLAUDE_CODE_FORCE_SANDBOX`。

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,441 @@
# Container execution for subagents (Track A)
[English](2026-09-12-agent-container-execution.md) | [简体中文](2026-09-12-agent-container-execution.zh-CN.md)
Status: backend and operator/definition policy implemented. Independent Linux Docker and rootful/rootless Podman verification covers `e544823995`; subsequent review fixes have local controlled-worker verification and still need native-runtime revalidation. Related: #11695, #11696, #9556.
## Problem and current state
Worktree isolation changes the working directory, but commands still run as the
host user. File tools also call host filesystem APIs during validation,
permission preparation, execution, and cache maintenance. Replacing
`FileSystemService` or the static shell entry alone cannot relocate execution.
The existing review command runner provides useful container policy: an explicit
environment allowlist, temporary HOME, named cleanup, and offline build/test
commands. Its review-specific mount layout is not a general workspace policy.
Install currently has ordinary container networking, not registry-only egress.
## Scope and activation
This change implements the container execution backend for ordinary subagents.
The parent model loop and its tools remain local. Each supported child has its
own environment; an operator container requirement applies to all ordinary
siblings. This is subagent execution policy, not whole-session confinement.
The trusted CLI operator requires container execution with
`QWEN_AGENT_EXECUTION_BACKEND=docker` or `podman`. Repository-sourced environment
values cannot enable or configure the runtime. Project `.env` and settings
values are excluded from initial loading and reload for this selector; reloading
or deleting a project value cannot overwrite or relabel an operator requirement.
For this selector, a nonempty file-sourced value (including a home `.env` or
inherited file provenance) is rejected with an instruction to export it in the
launch environment. It must never silently become a local default. Home-file
activation is not supported by this slice. A nonempty selector in merged
`settings.env`, including user settings, remains excluded and emits an export
remediation diagnostic during loading, runtime snapshots and reloads.
Agent has no `execution_backend` parameter. Definitions can request
`executionBackend: container`; omission
inherits the operator policy, and no definition value can weaken it. Without
either requirement execution stays local. The existing image override selects
the image; model arguments cannot select images, mounts, runtime endpoints, or
arbitrary container flags.
This initial backend is available on Unix hosts. Windows, whole-session sandbox
and Daemon/Serve handoffs have no factory. A required container with no available
factory fails before child launch; factory absence is not a local default.
Use an independently installed, complete CLI bundle. From the target project,
launch `QWEN_AGENT_EXECUTION_BACKEND=docker qwen`. Ordinary Agent dispatches then
require containers. Replace `docker` with `podman` when appropriate.
An operator can export `QWEN_CODE_CUSTOM_SANDBOX_IMAGE` for another toolchain.
The workspace must not overlap the CLI bundle or its dependency lookup directories
in either direction, including canonical aliases and currently absent lookup
directories. Before starting a container, the CLI checks its installation and
Node's dependency search directories for links escaping those protected roots.
This prevents a writable workspace alias from changing code the host later loads.
Hoisted dependencies and package links that stay within the protected roots are
supported when link targets are normalized (ordinary leading `../` is allowed).
Symlinked installation/search roots or ancestors, external or dangling links,
hard-linked installation files, and unreadable directories are rejected.
Shared hard-link package-store layouts are therefore unsupported.
This conservative check can also reject unrelated linked packages in a shared
Node search directory. In that case, use a local installation outside the shared
global package directory and launch
`QWEN_AGENT_EXECUTION_BACKEND=docker node --no-global-search-paths /path/to/independent/cli.js`.
The flag disables Node's global fallback lookups; it does not exclude ancestor
`node_modules` directories, so the independent installation still matters.
Source and tsc launches are unsupported. For CLI development, build, bundle and
prepare the npm package, then install it separately without source-workspace
links; use the target checkout or a separate worktree as the agent's workspace.
CLI relaunches preserve environment values for startup-time consumers such as
Node TLS certificates and settings interpolation. Private parent-to-child
metadata preserves their file provenance before environment files are loaded;
no file scope can supply this metadata. The metadata remains in the process
environment for ordinary child CLI launches, including Shell and review children,
and is refreshed on reload without losing the provenance of frozen or retained
values. Container runtime clients discard both the metadata and all file-sourced
values.
The policy composes with `isolation: "worktree"` and `working_dir`. It does not
extend the model-visible isolation enum or change `isolation: "remote"`.
Combining it with `tools.codeModeOnly` is rejected before container startup;
the first container registry supports direct tool calls only.
## Backend policy and definition boundaries
The initial model opt-in allowed an enabled operator capability to remain unused.
The revision separates an immutable Config requirement from the factory lifecycle.
Derived approval, worktree, workflow and resume contexts inherit that requirement,
including after shutdown. Resolve a definition's backend once per dispatch; use
it for validation, environment ownership, hooks and persisted metadata. Unknown
model arguments cannot override it.
Only the resolved string `container` is a valid definition value. Project
declarations require a trusted workspace, matching external executors. Null, `local`, other
values, duplicate keys, malformed YAML and invalid required fields must produce
a named refusal, not fall through to a lower-priority local agent. Refusals
reserve every declared top-level name, including middle duplicate keys. Preserve the
field through save, unrelated edits, extensions and SDK session objects. Validate
session objects when consumed; never filter invalid declarations and continue
initialization with a builtin. Explicit deletion removes only the definition
preference, never the operator floor. Claude plugin conversion preserves valid
declarations and leaves rejected source unchanged for the extension loader to
record its refusal. Daemon REST and ACP HTTP create/update reject the field within
their existing resolved-workspace and trust guards; no runtime is configured.
The CLI variable sets both capability and requirement. Core API hosts can inject
a factory without a default for definition-only selection. A definition cannot
provision missing capability. This slice adds no CLI capability-only setting.
Teams, Arena, workflows, external executors and retained regular/fork resumes
have no container lifecycle and refuse when container execution is required.
Workflow container refusals are run-level errors: sequential dispatch,
`parallel()`, `pipeline()` and nested compositions reject instead of returning
successful `null` slots. The journal does not record these policy refusals as
admitted-agent failures or results.
When the operator requires containers, Team creation and Arena startup refuse
before creating or reclaiming team files, resetting tasks or inboxes, attaching
team state, or provisioning Arena worktrees.
Direct Headless construction and the current in-process team backend must guard
before a local tool loop starts. Tool-capable internal forks (memory extraction,
dream, remember and skill review) also refuse; cache-only fork queries that
discard tool calls remain available. Do not clear derived policy to bypass these
limits. Daemon, managed runtime and SSH support remain out of scope. No policy
question remains open: definitions may strengthen, but cannot downgrade, the
operator floor.
Affected areas are Config and CLI activation, Agent resolution, definition
types/parser/serializer, plugin conversion, SDK type parity, direct runtime
construction and both daemon mutation protocols. Transport and worker lifecycle
follow the ownership and cancellation rules below.
Acceptance requires actual dispatch checks: omitted/forged model selectors cannot
run a required-container child locally; factory-unavailable cases fail loudly;
trusted definition-only selection works; malformed higher-priority declarations
refuse; save/SDK/plugin paths preserve policy; unsupported entry points and daemon
mutation protocols reject it. Unconfigured local dispatch is the control.
## Architecture
The harness, model credentials, authorization, and agent transcript remain on the
host. A container worker runs existing workspace tool implementations. The host
uses a typed execution contract carrying structured preparation information and
`ToolResult`, not a string-only tool protocol.
The contract includes preparation, confirmation, execution, modification, and
disposal. User modifications are bound to the scheduler call ID and discarded
when that call ends, so identical parameters cannot consume another call's edit.
Synchronous host tool construction validates the schema without
calling the original tool's filesystem-dependent build method. Original build
and path validation run in the worker. The host retains the tool's classifier
projection. Approval callbacks and modified content cross the same invocation
boundary; worker-only callback functions are never serialized as data.
The worker persists for its owning subagent so reads, edits, notebook preparation,
and background tool state share one tool session. Cache invalidation from the
harness must propagate to that session. The worker receives only the explicit
configuration required by tools; it does not receive model-provider credentials,
MCP credentials, the parent Config object, or a model client.
The explicit tool configuration preserves file filtering, new-file encoding,
shell timeout and heartbeat settings, output capture limits, and truncation
settings, including an unset threshold and zero meaning unlimited output.
Automatic session artifact registration is unavailable in container subagents.
The worker disables it even when an image enables artifacts through environment
variables or a write requests `record_as_artifact: true`. Files remain in the
workspace, but the worker does not claim they were registered. The container
Write tool advertises this limit; the host continues to discard worker artifact
metadata and does not interpret it as host paths. Ordinary local writes retain
their existing artifact behavior. A host-validated artifact bridge, including
child worktree-to-parent session path ownership, is deferred.
File-history checkpoints, host-path IDE diffs and automatic path-triggered
rule/skill activation are unavailable in container subagents; they must not read
container-reported paths through the host filesystem. Inline confirmation diffs
use the worker's original and proposed content. ToolSearch runs in the host
harness against only the private tool registry, preserving deferred discovery
and permission denials. Container agents refresh their declarations before each
model round; revealing a tool never refreshes the parent's model client.
Execution startup is lazy within the explicitly selected environment. The worker
is bundled with the installed CLI and mounted read-only, avoiding an independently
versioned worker supplied by the image. Development verification uses a separately
installed package built from the same source.
## Execution ownership
| Surface | Owner in a container subagent |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Read, Write, Edit, NotebookEdit, Glob, Grep, directory listing | Container worker, including preparation and path resolution |
| Shell, shell task output and stopping | Container execution session; host PIDs never represent container PIDs |
| Approval decisions, invocation guard, model requests, transcript | Host harness |
| Worktree creation, change detection, preservation and removal | Host, after execution cleanup |
| Parent `!` commands and `@` file expansion | Parent's environment; this change introduces no session-level backend |
| Monitor, nested Agent, external ACP agents, custom command tools, skills, LSP and MCP | Not available in this first container tool registry |
| User or subagent executable hooks | Incompatible combinations are rejected, not silently disabled |
Container subagents cannot silently obtain host-bound discovered tools from their
parent. Unsupported combinations fail before starting the subagent. No container
failure retries an operation on the local backend.
## Filesystem and Git boundary
Bind only the resolved working tree at its canonical absolute path. Do not widen
the mount to satisfy dependency symlinks outside that tree. Use a temporary HOME;
do not mount the host HOME, credential stores, runtime directory, or container
socket. The same-path mapping reduces translation but is not authorization:
actual path resolution and file operations occur inside the container.
Protected host directories are resolved through their existing ancestors even
when the directory itself has not been created. A symlink alias must not hide
their containment in the writable workspace.
A separate temporary output directory is mounted at the same path in both the
primary and installation workers. The harness writes truncated tool output there
as well, so recovery paths remain readable after an installation worker exits.
This directory contains tool output only and is removed with the owning agent;
it does not expose the host runtime directory.
The temporary root must be outside the writable workspace, including after
resolving symlinks, so the worker cannot replace the output directory's parents.
Host persistence creates shared files exclusively, including truncation and
fallback writes, without reopening entries or changing permissions by path.
Existing entries are never overwritten by the host; failed writes use the
existing recovery path. Workers can still modify their shared output contents.
Always mask the working tree's `.git` entry with an empty read-only mount on
both the primary and installation workers, even when the entry is initially
absent. This also prevents container code from creating root Git metadata that
host Git clients could subsequently interpret, including configuration that
runs external programs. Use a directory mask for an absent entry or directory and a file mask
for a regular file. Reject symbolic links and other entry types. Recheck before
container creation and attachment; a file/directory kind change requires a new
agent environment. These checks are not atomic with concurrent host filesystem
changes.
For an absent entry, the CLI creates an empty mount-point directory as the host
user before starting the runtime, and records its device and inode. Sibling
environments in the same CLI process share its ownership. After all owning
workers have stopped, the last environment removes only that same empty directory
with a non-recursive removal. Pre-existing entries, replacements and directories
that gained contents are preserved. Failed worker cleanup retains ownership and
the backing mask. A later disposal call, including root-session shutdown after
agent cleanup failed, retries failed removals without reopening execution.
Concurrent calls share the current attempt; successful removals and lease
releases are not repeated. Filesystem cleanup can also be retried without
releasing another sibling's lease. A hard exit can leave the mount point behind;
there is no crash reaper or coordination between independent CLI processes. Do not run
independent CLI processes concurrently in the same non-Git workspace. While in
use, the placeholder is visible to host-side repository discovery too.
The container sees an empty read-only `.git` even for a non-Git workspace.
Existence-gated package lifecycle scripts may take their Git branch and fail
when they propagate the Git error; this does not imply that all hook installers
fail. In-container repository detection can also change ignore-file filtering
and select Git-backed search strategies. File discovery may therefore omit
paths that the parent, initialized before the placeholder, still lists.
In-container `git init` at the workspace root is unsupported. Linked
worktrees refer to a shared common directory which contains parent and sibling
state and may contain credentials. Only the selected workspace root's `.git` is
masked. Nested repositories and
submodules inside the workspace remain readable and writable workspace content,
including any credentials stored there. Their repository-local configuration
can name programs that host Git clients later execute; container-written files
may have the invoking user's ownership. Container isolation does not make those
files safe for subsequent host execution. This is not a repository-secret filter.
The host still sees the actual changes and applies the existing worktree
preservation rules. Git
commit transfer or a private Git metadata view requires a separate design.
## Network and credentials
General commands, file operations, builds, tests run without
network access. An explicitly recognized standalone package installation may run
in a separate install container sharing the working files. Command classification
must reject compound shell expressions as installation requests. This exception
allows ordinary networking, including package lifecycle scripts and access to
the writable workspace. Scripts can transmit workspace contents. Lifecycle
scripts remain enabled for dependency/toolchain compatibility; registry-only
egress or mandatory `--ignore-scripts` would be a separate policy change.
Both paths use the same explicit environment allowlist and temporary HOME. No
model, GitHub, cloud or MCP credentials are forwarded. Images and the container
runtime are trusted infrastructure. Rootless detection reads only Docker
`SecurityOptions` and Podman `Host.Security.Rootless` or `host.security.rootless`;
unrelated labels and unknown shapes do not disable host UID/GID mapping.
Rootful runtimes use the invoking host
UID/GID; a root operator therefore runs UID 0 inside the container. The dropped
capabilities and `no-new-privileges` still apply; UID mapping does not guarantee
an unprivileged user. This first backend is unavailable inside
the existing whole-session sandbox or daemon/embedded-bridge ACP children:
those handoffs do not preserve environment provenance. A forced container
request fails as unavailable; the Docker socket
is never automatically mounted.
Inherited file provenance marks trust without giving the child ownership of the
ancestor's reload scope. Reload only removes keys known from the child's own
files or settings; surviving ancestor values retain their provenance in later
child CLIs. A malformed definition reserves every usable top-level AST name when
refusing an execution declaration. The lenient parser's name is used only when
the AST resolves no usable name; prose cannot add names alongside AST declarations.
## Lifecycle and recovery
The selected environment owns every container name before startup and performs
idempotent cleanup on startup failure, foreground completion, background
completion, cancellation, error and teardown. Await cleanup before host worktree
inspection or removal. Killing only the host runtime client is insufficient.
Tool invocation resources are released on scheduler terminal states, including
host permission denials. If installation execution finishes but cleanup fails,
return its original output with a cleanup warning, retain the worker's ownership,
and fail session cleanup rather than remove its workspace. Foreground and
background agent completion also retain their result and termination status
with a cleanup warning; the root session keeps ownership of failed resources.
A cleanup failure is
not grounds for replaying an already completed command. Automatic history
compression invalidates the worker cache directly across derived Config layers.
An invalidation failure is logged without abandoning the already compressed
history and token bookkeeping; the next tool still requires successful cache
synchronization. Failed path-specific invalidation after microcompaction also
advances the host cache generation, so the next tool must resynchronize before
reading; already compressed history and its result are retained.
Memory-only cache eviction does not invalidate worker reads.
Container cleanup failure reports preserved worktrees only when Agent created
them. Caller-owned `working_dir` worktrees retain caller ownership and are never
reported as leftover worktrees created by Agent.
Permission preparation receives the caller cancellation signal, defaulting to
the invocation-owned signal when omitted. Releasing an invocation cancels
pending preparation and permission; abort listeners are removed first to avoid
reentrant release. Its release RPC has an independent
30-second timeout so an unresponsive worker cannot strand cancellation cleanup.
Container creation may pull a cold
image and has no fixed 30-second limit; it remains cancellable. With
`QWEN_DEBUG_LOG_FILE=1`, the session debug log records the runtime, container name,
image and possible pull before waiting for creation. Nonempty `ServerErrors`
from runtime metadata abort startup before temporary directories or containers
are allocated, even when the command exits successfully. A failed create still
requires cleanup because its runtime-side outcome may be unknown. Runtime metadata
and removal commands retain their 30-second limit. If startup cleanup fails,
its error retains a disposal callback for the root session; the failed factory
promise remains owned until that cleanup succeeds. Completed cleanup attempts
may be retried after failure. A timed-out attempt stays shared until its actual
work settles, so another shutdown cannot start concurrent recovery.
Session shutdown aborts
pending startup and begins container disposal before other exit cleanups. Its
wait is bounded at one second, within the CLI's unchanged two-second per-step
and five-second overall exit limits. A timeout or removal failure remains
visible and preserves resource/workspace ownership; unfinished disposal reports
the container names and temporary directory. Cleanup can continue until the
process exits, but successful removal is not claimed when the deadline expires.
The first version does not resume a disposed container subagent. Both discovery
and direct resume must reject it. Persist a container isolation marker in the
sidecar as a compatibility guard understood by older readers as unsupported;
persist the working-tree choice separately. This wire-format guard does not
change the Agent tool's isolation enum. An unknown new backend field alone is
insufficient because old readers ignore unknown properties.
Container loss becomes a tool error. A lost response after a possible write is
reported as an unknown outcome; the harness never automatically replays it.
Cancellation rejects only its request and sends a scoped cancellation message;
other requests and later tool calls remain usable. An interrupted execution
still warns about possible writes, and no operation is automatically replayed.
A housekeeping timeout can be followed by another cache synchronization attempt.
Malformed protocol responses and broken transports still fail the whole worker;
silently skipping corruption would hide a possibly lost result, so transport
recovery is deferred.
The worker and its tool subprocesses currently share a UID and PID namespace.
The stdin/stdout protocol has no authentication or separate identity protecting
it from those subprocesses. Where the runtime permits access to the worker's
file descriptors, in-container code can inject a valid reply, not just a malformed
line. The host accepts the first matching reply, including preparation parameters,
permission decisions, confirmation details and tool results. A forged edit
confirmation can also affect the child's host-side approval mode if the user
chooses to allow future edits. Request IDs and malformed-response rejection do not establish
reply authenticity. Treat this channel as part of the untrusted container;
Track A does not provide trustworthy tool reporting or permission mediation
against hostile in-container code. Separating the endpoint and payload identities
requires a follow-up design that preserves workspace ownership and rootless
runtime compatibility. No such separation is implemented in this slice.
A host exit before cleanup completes, including `SIGKILL` or an expired shutdown
deadline, can leave containers, temporary output directories and a session-created
empty workspace `.git` mount point. That empty entry can affect project-root
discovery. Failed removal retains these resources for retry while the process is
alive; they are released only after worker removal succeeds. There is no startup
sweeper in this slice, and a later session preserves pre-existing `.git` entries.
Operators must verify ownership and that execution has stopped before manual
removal. A cross-session reaper requires separate ownership/race rules.
## Affected areas
- Core execution contract, local tool session, container transport and worker.
- Agent tool parameters, Config derivation, registry ownership and cleanup.
- Agent metadata and both background resume paths.
- File-read cache invalidation; the existing filesystem service API stays unchanged.
- CLI trusted capability activation and shared review container policy.
- Bundle entry points, focused contract tests and E2E fixtures.
## Validation and acceptance
Run common contract cases through local and container tool sessions: read/write,
read-before-edit, notebook edits, search, errors, streaming, cancellation and
disposal. Run host integration tests proving parent/sibling isolation, no host
filesystem fallback, approval/classifier preservation and incompatible resume
rejection. Assert container argv, environment, mounts and network classification.
Drive the global CLI first to record the unsupported baseline, then the built
CLI with a deterministic model fixture. With a real Docker or Podman runtime,
verify inaccessible host sentinel credentials, worktree persistence, denied
networking, installation, process cleanup and container death. Simulated runtime
tests are protocol evidence, not evidence of kernel isolation. Report unavailable
real-runtime tests explicitly.
[Independent Linux verification](https://github.com/QwenLM/qwen-code/pull/11711#issuecomment-5645312113)
reports ten harness suites and thirteen CLI arms at `ebae028529` with rootful
Docker 26.1.5. It used the published 0.23.0 image because 0.23.3 could not be
pulled, and a local HTTP tarball server because the bridge lacked public egress.
Podman, a live rootless daemon and the configured 0.23.3 image remain unverified
by that report. These are external results, distinct from local process-fixture
checks.
[Independent Linux round 2](https://github.com/QwenLM/qwen-code/pull/11711#issuecomment-5652725410)
reports policy and lifecycle verification at `e544823995`, including fourteen
boundary checks on each of Docker 26.1.5 and rootful/rootless Podman 5.4.2 with
the configured 0.23.3 image. It used a copied independent bundle, a local HTTP
tarball server, and rootless Podman via `runuser` with cgroupfs and tmpfs storage.
Public-registry egress, npm packed installation and subsequent review fixes are
outside that report. Its results are attributed external evidence, not local
runtime verification.
Build, typecheck, bundle, run focused tests, then perform two clean self-audit
passes and the repository code-review workflow before declaring completion.
## Deferred capabilities
Remote execution and environment resources are Track C. Credential proxies are
Track B. Git metadata transfer, nested container agents, container-session resume,
external tools and image/audio/video reading (including native inline media and
provider-backed vision processing) are outside this first backend. The worker
does not receive model modality configuration. Unsupported media reads report
that limitation and must not silently fall back to host execution.

View file

@ -0,0 +1,318 @@
# 子代理容器执行(Track A)
[English](2026-09-12-agent-container-execution.md) | [简体中文](2026-09-12-agent-container-execution.zh-CN.md)
状态:后端及操作者/定义策略已实现。独立 Linux Docker 与 rootful/rootless Podman 验证覆盖 `e544823995`;此后的评审修复已有本地受控 worker 验证,仍需原生运行时复验。关联:#11695、#11696、#9556。
## 问题与现状
Worktree 隔离改变工作目录,但命令仍以宿主用户身份执行。文件工具在校验、权限准备、
执行和缓存维护阶段也直接调用宿主文件系统 API。仅替换 `FileSystemService` 或静态
shell 入口,无法迁移整个执行面。
现有 review 命令执行器提供了有用的容器策略:显式环境白名单、临时 HOME、按名称清理,
以及离线 build/test 命令。它专用于 review 的挂载布局不是通用工作区策略。
目前 install 使用普通容器网络,并没有仅限 registry 的出口控制。
## 范围与启用
本变更为普通子代理实现容器执行后端。父级模型循环及工具仍在本地运行。
每个受支持的子代理拥有独立环境;操作者的容器要求适用于所有普通兄弟子代理。
这是子代理执行策略,不是整会话隔离。
可信 CLI 操作者使用 `QWEN_AGENT_EXECUTION_BACKEND=docker` 或 `podman` 要求容器执行。
来自仓库文件的环境值不能启用或配置运行时。项目 `.env` 和 settings 中的该选择项在
首次加载及重载时均被排除;重载或删除项目值不能覆盖操作者要求或改变它的来源标记。
对于这个选择项,来自文件的非空值(包括 home `.env` 或继承的文件来源标记)会被明确拒绝,
并提示在启动环境中 export。不得静默回退为本地执行;本阶段不支持通过 home 文件启用。
合并后的 `settings.env`(包括用户设置)中的非空选择项仍被排除,
并在加载、运行时快照构建和重载时输出要求 export 的诊断。
Agent 不提供 `execution_backend` 参数。
定义可声明 `executionBackend: container`;省略时继承操作者策略,定义不能降低该要求。
两者均未要求容器时,继续本地执行。
镜像使用现有 sandbox image 覆盖机制;模型参数不能指定镜像、挂载、运行时端点或任意容器标志。
首版后端仅在 Unix 宿主上提供。Windows、整会话 sandbox 和 Daemon/Serve 交接不提供 factory。
要求容器但 factory 不可用时,在启动子代理之前失败;factory 缺失不表示回退本地。
使用独立安装、完整打包的 CLI,在目标项目中启动
`QWEN_AGENT_EXECUTION_BACKEND=docker qwen`,随后普通 Agent 调度均要求容器执行。
使用 Podman 时将 `docker` 换为 `podman`。
操作者可导出 `QWEN_CODE_CUSTOM_SANDBOX_IMAGE` 选择其他工具链镜像。
工作区不能与 CLI bundle 或依赖查找目录在任一方向重叠,包括规范化后的别名和当前尚不存在的查找目录。
启动容器前,CLI 检查安装目录及 Node 依赖查找目录中是否存在指向保护范围外的链接,
防止通过可写工作区别名修改宿主随后加载的代码。
支持上移的依赖和始终留在保护范围内、目标已经规范化的包链接(允许普通的前导 `../`)。
拒绝安装/查找根目录及其祖先路径中的符号链接、外部或悬空链接、安装文件的硬链接和不可读取的目录。
因此不支持通过硬链接共享包存储的安装方式。
保守检查也可能拒绝共享 Node 查找目录中无关的链接包。
此时应在共享全局包目录之外本地独立安装,并启动
`QWEN_AGENT_EXECUTION_BACKEND=docker node --no-global-search-paths /path/to/independent/cli.js`。
该标志禁用 Node 的全局回退查找,但不会排除祖先 `node_modules` 目录,因此仍须独立安装。
不支持源码和 tsc 启动。开发 CLI 时,先构建、打包并准备 npm 包,
再独立安装且不保留源码工作区链接;代理工作区可使用目标 checkout 或独立 worktree。
CLI 重启时保留环境变量值,供 Node TLS 证书、设置插值等启动早期消费者使用。
父子进程通过私有元数据在加载环境文件之前恢复文件来源;任何作用域的文件都不能设置该元数据。
元数据保留在进程环境中,以覆盖 Shell、review 等普通子 CLI 启动;重载时同步更新,
保留被冻结或因读取失败而保留的值的来源。容器运行时客户端丢弃该元数据和全部文件来源的环境变量。
该策略可与 `isolation: "worktree"` 和 `working_dir` 组合。
它不扩展面向模型的 isolation 枚举,也不改变 `isolation: "remote"`。
与 `tools.codeModeOnly` 的组合会在容器启动前被拒绝;首版容器注册表只支持直接工具调用。
## 后端策略与定义边界
最初由模型主动选择的方式允许已启用的操作者能力不被使用。修订后,Config 中不可变的
要求与 factory 生命周期分离。派生的授权、worktree、workflow 和恢复上下文继承该要求,
关闭之后也不例外。每次调度只解析一次定义后端,校验、环境归属、hooks 和持久化元数据
均使用该结果。未知模型参数不能覆盖它。
定义解析后只允许字符串值 `container`。项目声明须位于可信工作区,与外部 executor 一致。
null、`local`、其他值、重复键、损坏的 YAML 及无效必填字段均须按声明名称拒绝,
不得回退到较低优先级的本地代理。拒绝记录覆盖所有顶层声明名称,包括重复键中的中间项。保存、无关字段编辑、扩展和 SDK 会话对象均保留该字段。
消费会话对象时校验;不得过滤无效声明后继续初始化并使用内置代理。显式删除仅移除定义
偏好,不能移除操作者的最低要求。Claude 插件转换保留有效声明,被拒绝的源文件保持原样,
供扩展加载器记录拒绝。Daemon REST 和 ACP HTTP 创建/更新在现有已解析工作区及信任
检查内拒绝该字段,不配置运行时。
CLI 变量同时设置能力和要求。Core API 宿主可只注入 factory 而不设置默认要求,允许定义
单独选择。定义不能创建缺失的能力。本轮不新增 CLI 的仅启用能力设置。
Team、Arena、workflow、外部 executor 和保留的普通/fork 恢复路径没有容器生命周期,
要求容器时拒绝。Workflow 容器拒绝属于运行级错误:顺序调用、`parallel()`、`pipeline()`
及嵌套组合都会拒绝,而不会成功返回 `null` 槽位。Journal 不将这种策略拒绝记录为
已准入代理的执行失败或结果。
操作者要求容器时,Team 创建和 Arena 启动在创建或回收 Team 文件、
重置任务或收件箱、挂载 Team 状态、创建 Arena worktree 之前拒绝。
直接构造 Headless 和当前 in-process Team 后端须在本地工具循环开始前
检查。可调用工具的内部 fork(记忆提取、dream、remember 和 skill review)也拒绝;
明确丢弃工具调用的缓存查询 fork 仍可用。不得清除派生策略来绕过限制。
Daemon、managed runtime 和 SSH 支持仍不在范围内。没有未决策略:定义可增强,
不能降低操作者的最低要求。
影响范围包括 Config 和 CLI 启用、Agent 解析、定义类型/解析/序列化、插件转换、SDK 类型
同步、直接运行时构造和两种 Daemon 修改协议。传输和 worker 生命周期遵循下文的归属与取消规则。
验收必须检查实际调度:省略或伪造模型选择参数不能使要求容器的子代理本地执行;factory
不可用时明确失败;可信定义单独选择有效;损坏的高优先级声明被拒绝;保存/SDK/插件
路径保留策略;不支持的入口和 Daemon 修改协议拒绝它。未配置策略的本地调度作为对照。
## 架构
Harness、模型凭证、授权和代理 transcript 留在宿主。容器 worker 执行现有工作区工具。
宿主使用有类型的执行契约,携带结构化准备信息和 `ToolResult`,而非纯字符串工具协议。
契约覆盖准备、确认、执行、修改和释放。用户修改绑定调度器的调用 ID,调用结束时丢弃,
因此相同参数的其他调用无法消费这份修改。宿主的同步工具构建只验证 schema,不调用原工具
依赖文件系统的 build 方法。原始 build 和路径校验在 worker 内运行。宿主保留工具的
classifier 参数投影。批准回调与修改后的内容经过同一个 invocation 边界;
worker 内的回调函数不会被当成数据序列化。
Worker 随所属子代理持续存在,使读取、编辑、notebook 准备及后台工具状态共享同一个
工具会话。Harness 的缓存失效必须传播到该会话。Worker 只接收工具必需的显式配置,
不接收模型提供商凭证、MCP 凭证、父 Config 对象或模型客户端。
显式工具配置保留文件过滤、新文件编码、shell 超时和心跳设置、输出捕获上限以及
截断设置,包括未设置阈值和零表示不限制输出的语义。
容器子代理暂不提供自动会话产物登记。即使镜像通过环境变量开启产物功能,
或写入请求设置 `record_as_artifact: true`,worker 也禁用该能力。文件仍保留在
工作区中,但 worker 不会声称已登记。容器 Write 工具说明明确这一限制;宿主继续
丢弃 worker 产物元数据,不将其当作宿主路径解释。普通本地写入保留原有产物行为。
经过宿主校验的产物桥接(包括子 worktree 与父会话之间的路径归属)留待后续实现。
容器子代理不提供文件历史检查点、基于宿主路径的 IDE diff 和按路径自动触发的
rules/skills 激活,避免依据容器返回的路径读取宿主文件。内联确认 diff 使用 worker
提供的原文和修改内容。ToolSearch 在宿主 harness 内仅查询私有工具注册表,
保留延迟发现与权限拒绝行为。容器代理在每轮模型请求前刷新自己的工具声明;
揭示工具不会刷新父代理的模型客户端。
显式选择环境后才惰性启动执行。Worker 随已安装的 CLI 一起打包,并以只读方式挂载,
避免使用镜像中独立版本的 worker。开发验证使用由同一源码构建并独立安装的包。
## 执行归属
| 执行面 | 容器子代理中的归属 |
| ----------------------------------------------------------------------- | ------------------------------------- |
| Read、Write、Edit、NotebookEdit、Glob、Grep、目录列表 | 容器 worker,包括准备与路径解析 |
| Shell、shell 任务输出与停止 | 容器执行会话;宿主 PID 不代表容器 PID |
| 授权决定、invocation guard、模型请求、transcript | 宿主 harness |
| Worktree 创建、变更检测、保留与移除 | 宿主,执行资源清理之后 |
| 父会话 `!` 命令和 `@` 文件展开 | 父会话环境;本变更不引入会话级后端 |
| Monitor、嵌套 Agent、外部 ACP agent、自定义命令工具、skills、LSP 和 MCP | 首版容器工具注册表不提供 |
| 用户或子代理的可执行 hooks | 拒绝不兼容组合,不静默禁用 |
容器子代理不能静默继承父级中绑定宿主的 discovered tools。
不支持的组合在子代理启动前失败。任何容器失败都不得转到本地后端重试。
## 文件系统与 Git 边界
只将解析后的工作树绑定到其规范化绝对路径。不得为满足工作树外的依赖符号链接扩大挂载。
使用临时 HOME;不挂载宿主 HOME、凭证存储、运行时目录或容器 socket。
同路径映射减少转换,但不构成授权:真正的路径解析和文件操作发生在容器内。
受保护的宿主目录即使尚未创建,也须通过已存在的祖先解析实际路径,
不能让符号链接别名掩盖其位于可写工作区内的事实。
另将独立的临时输出目录以相同路径挂载给主 worker 和安装 worker。
Harness 也将截断后的完整工具输出写入此处,使安装 worker 退出后,恢复路径仍可读取。
该目录仅包含工具输出,随所属代理清理,不暴露宿主运行时目录。
临时根目录在解析符号链接后也必须位于可写工作区外,防止 worker 替换输出目录的父目录。
宿主持久化(包括截断和回退写入)以独占方式创建共享文件,不重新打开条目或按路径修改权限。
宿主不覆盖已有条目;写入失败沿用现有恢复路径。Worker 仍可修改共享输出内容。
主 worker 和安装 worker 始终使用空的只读挂载遮盖工作树的 `.git` 条目,即使该条目
最初不存在。这也阻止容器代码创建随后可能被宿主 Git 客户端解释的根 Git 元数据,
包括会运行外部程序的配置。不存在或为目录时使用目录遮盖,普通文件使用文件遮盖;拒绝符号链接及其他
条目类型。在创建和附加容器之前重新检查;文件与目录类型变化时,必须创建新的代理环境。
这些检查与宿主并发文件系统变更之间不具备原子性。
条目不存在时,CLI 在启动运行时前以宿主用户身份创建空挂载点目录,并记录设备号及 inode。
同一 CLI 进程内的兄弟环境共享其归属;所有拥有者的 worker 都停止后,最后一个环境仅以
非递归方式移除身份仍匹配的空目录。预存条目、替换条目以及已经写入内容的目录均保留。
Worker 清理失败时保留归属和作为挂载源的遮盖。后续释放调用(包括代理清理失败后的
根会话关闭)会重试失败的移除,同时继续拒绝执行。并发调用共享当前尝试;已经成功的
移除和归属释放不重复执行。文件系统清理也可以重试,不会重复释放其他兄弟的归属。
进程硬退出仍可能留下挂载点;没有崩溃回收器,
也不协调独立 CLI 进程。不要让独立 CLI 进程同时使用同一个非 Git 工作区。使用期间,
宿主的仓库发现逻辑也能看到该占位目录。
即使工作区不是 Git 仓库,容器内也会看到空的只读 `.git`。按条目存在性判断的包生命周期
脚本可能进入 Git 分支,并因传播 Git 错误而安装失败;这不意味着所有 hook 安装器都会失败。
容器内的仓库检测还可能改变忽略文件过滤并选择基于 Git 的搜索策略。因此,文件发现可能省略
在占位目录创建前初始化的父代理仍会列出的路径。不支持在容器内工作区根目录运行 `git init`。
Linked worktree 引用包含父级、兄弟状态及
潜在凭证的共享 common directory。只遮盖选定工作区根目录的 `.git`。工作区内的嵌套仓库和 submodule 仍属于可读写的
工作区内容,其中保存的凭证也可访问。其仓库本地配置可能指定宿主 Git 客户端随后执行的
程序,容器写入的文件也可能具有调用用户的归属。容器隔离不保证这些文件随后在宿主执行时安全;
这不是仓库秘密过滤器。
宿主仍能看到实际变更,并执行现有 worktree 保留规则。
Git 提交传递或私有 Git 元数据视图需要单独设计。
## 网络与凭证
普通命令、文件操作、build 和 test 均无网络。
明确识别的独立依赖安装命令可以在共享工作文件的另一安装容器中运行。
命令分类必须拒绝将复合 shell 表达式视作安装请求。
该例外允许普通网络,包括执行包生命周期脚本并访问可写工作区;脚本可以传输工作区内容。
为兼容依赖及工具链,继续允许生命周期脚本。仅限 registry 的出口或强制 `--ignore-scripts`
属于另外的策略变更。
两条路径使用相同的显式环境白名单及临时 HOME,不转发模型、GitHub、云或 MCP 凭证。
镜像与容器运行时属于可信基础设施。Rootless 判断仅读取 Docker `SecurityOptions`
以及 Podman `Host.Security.Rootless` 或 `host.security.rootless`;
无关标签和未知元数据结构不会关闭宿主 UID/GID 映射。
Rootful 运行时使用调用者的宿主 UID/GID,
因此 root 操作者会在容器内以 UID 0 运行。移除 capabilities 和 `no-new-privileges`
仍然生效;UID 映射不保证使用非特权用户。
首版不在现有整会话 sandbox 或 daemon/内嵌 bridge 启动的 ACP 子进程内提供该后端,
因为这些启动交接不保留环境来源。
强制请求容器后端时返回明确的不可用错误,不得自动挂载 Docker socket。
继承的文件来源只标记信任边界,不授予子进程对祖先进程重载作用域的所有权。
重载只删除子进程自身文件或设置中已知的键;保留的祖先值继续向后续子 CLI 传递来源。
畸形定义在拒绝执行声明时,会保留 AST 中所有可用的顶层名称。
只有 AST 无法解析出任何可用名称时,才回退到宽松解析器的名称;
正文中的名称不能追加到 AST 声明的名称中。
## 生命周期与恢复
选定环境在启动前记录每个容器名称,并在启动失败、前台完成、后台完成、取消、错误及
会话拆除时幂等清理。必须等待清理结束,再由宿主检查或移除 worktree。
仅终止宿主上的容器客户端不够。
调度器在工具终态释放 invocation 资源,包括宿主权限拒绝的调用。
安装执行结束但清理失败时,返回原始输出并附清理告警,保留 worker 归属,
让会话清理失败而不是删除工作区。前台及后台代理完成时也保留结果和终止状态,
附上清理告警;根会话继续拥有清理失败的资源。不能因为清理失败就重放已经完成的命令。
自动压缩历史会跨派生 Config 层级直接清理 worker 缓存。
失效请求失败会记录日志,但仍完成已经压缩的历史对应的 token 统计;下一次工具调用
仍须成功同步缓存才能继续。微压缩后的按路径失效请求失败时,也清理宿主缓存并推进其代次,
要求下一次工具读取前重新同步;已经压缩的历史及其结果仍保留。
仅回收内存时不会清理 worker 的已读记录。
容器清理失败时,仅对 Agent 创建的 worktree 报告已保留;
调用方通过 `working_dir` 提供的 worktree 始终归调用方所有,不报告为 Agent 创建的遗留工作区。
权限准备接收调用者的取消信号,省略时使用 invocation 自己拥有的信号。
释放 invocation 会中止仍未完成的准备和权限请求;须先移除取消监听器,避免释放重入。
释放 RPC 使用独立的 30 秒超时,防止 worker 无响应导致取消清理永久等待。
创建容器可能需要先下载镜像,因此不受固定 30 秒上限限制,但支持取消。
启用 `QWEN_DEBUG_LOG_FILE=1` 后,会话调试日志会在等待创建之前记录运行时、容器名称、
镜像及可能发生的拉取。运行时元数据含非空 `ServerErrors` 时,即使命令成功退出,
也会在分配临时目录或容器之前终止启动。创建失败后仍须尝试清理,因为运行时侧结果可能未知。
运行时元数据查询和删除命令仍保留 30 秒上限。
若启动失败后的清理也失败,错误会保留供根会话调用的释放回调;
在清理成功之前,根会话继续持有失败的 factory Promise。
清理尝试结束并失败后可以重试。超时的尝试会共享到实际工作结束,
避免再次关闭时并发启动恢复清理。
会话退出会取消仍在启动的容器,并在其他退出清理之前开始容器释放。
等待上限为一秒,处于 CLI 原有的单步骤两秒、整体五秒退出期限内。
超时或删除失败必须可见,并保留资源和工作区归属;尚未完成的释放会报告容器名称及临时目录。
进程退出前清理可以继续,但到达期限时不得宣称删除成功。
首版不恢复已释放的容器子代理。扫描发现和直接恢复两条路径都必须拒绝。
在 sidecar 中写入容器 isolation 标记,使旧版读取器将其视为不支持的值;
工作树选项单独持久化。该协议兼容标记不改变 Agent 工具的 isolation 枚举。
仅新增未知后端字段不够,因为旧版读取器会忽略未知属性。
容器丢失表现为工具错误。可能已发生写入而响应丢失时,报告结果未知;
harness 不自动重放操作。取消只拒绝对应请求,并发送按请求范围生效的取消消息,
其他请求及后续工具调用仍可使用。中断执行仍会提示可能已经写入,不会自动重放任何操作。
缓存等内务请求超时后可以再次尝试同步。损坏的协议响应或断开的传输仍会使整个 worker 失败;
静默跳过协议损坏可能掩盖丢失的结果,因此传输恢复能力后续再做。
Worker 与其工具子进程目前共享 UID 和 PID 命名空间。stdin/stdout 协议没有身份认证,
也没有通过独立身份将端点与子进程隔离。在运行时允许访问 worker 文件描述符的情况下,
容器内代码可以注入有效回复,而不仅是损坏的行。宿主接受第一个匹配的回复,包括准备参数、
权限决定、确认详情及工具结果。伪造的编辑确认还可能在用户选择允许后续编辑时影响该
子代理在宿主侧的审批模式。请求 ID 和损坏响应检测不能证明回复的真实性。应将该通道视为不可信容器的
一部分;面对恶意容器内代码,Track A 不提供可信工具报告或权限调解保证。端点与执行
进程身份分离需要后续设计,同时保持工作区文件归属和 rootless 运行时兼容性。本阶段
尚未实现这种隔离。
清理完成前宿主就退出(包括 `SIGKILL` 或关闭期限届满)时,可能留下容器、临时输出目录,
以及会话创建的工作区空 `.git` 挂载点。该空目录可能影响项目根目录发现。
删除失败时,进程存活期间会保留这些资源以供重试;只有 worker 删除成功后才会释放。
首版没有启动清扫器,后续会话会保留已经存在的 `.git` 条目。
操作者手动移除之前必须确认资源归属且执行已经停止。
跨会话回收器需要另外明确归属和竞态规则。
## 影响范围
- Core 执行契约、本地工具会话、容器传输与 worker。
- Agent 工具参数、Config 派生、注册表归属和清理。
- Agent 元数据和后台恢复的两条路径。
- 文件读取缓存失效;现有文件系统服务 API 保持不变。
- CLI 可信能力启用及共享的 review 容器策略。
- Bundle 入口、针对性契约测试及 E2E fixture。
## 验证与验收
对本地和容器工具会话运行同一组契约用例:读写、先读后改、notebook 编辑、搜索、错误、
流式输出、取消和释放。运行宿主集成测试,证明父级及兄弟隔离、不回退宿主文件系统、
授权与 classifier 投影保留,以及拒绝不兼容恢复。
断言容器 argv、环境、挂载与网络分类。
先用全局 CLI 记录不支持的基线,再用确定性模型 fixture 驱动构建后的 CLI。
有真实 Docker 或 Podman 时,验证宿主标记凭证不可读、工作树持久化、网络拒绝、安装、
进程清理和容器死亡。模拟运行时测试只证明协议行为,不证明内核隔离。
明确报告无法执行的真实运行时测试。
[独立 Linux 验证](https://github.com/QwenLM/qwen-code/pull/11711#issuecomment-5645312113)
报告在 `ebae028529` 上使用 rootful Docker 26.1.5 完成十组 harness 和十三条 CLI 验证。
因无法拉取 0.23.3,使用已发布的 0.23.0 镜像;因 bridge 无公网出口,使用本地 HTTP tarball 服务。
该报告仍未验证 Podman、真实 rootless daemon 和配置的 0.23.3 镜像。
这些是外部报告结果,与本机进程夹具检查分别记录。
[独立 Linux 第 2 轮](https://github.com/QwenLM/qwen-code/pull/11711#issuecomment-5652725410)
报告在 `e544823995` 上验证策略及生命周期,使用配置的 0.23.3 镜像,
在 Docker 26.1.5、rootful Podman 5.4.2 和 rootless Podman 5.4.2 上各完成十四项边界检查。
该报告使用复制的独立 bundle、本地 HTTP tarball 服务,以及通过 `runuser` 配合 cgroupfs
和 tmpfs 存储运行的 rootless Podman。公网 registry 出口、npm 打包安装及此后的评审修复
不在该报告范围内。这些是具名引用的外部证据,并非本地运行时验证。
完成 build、typecheck、bundle 和针对性测试,随后进行两次干净的自审以及仓库的
代码审查流程,再声明完成。
## 后续能力
远程执行和环境资源属于 Track C;凭证代理属于 Track B。
Git 元数据传递、嵌套容器代理、容器会话恢复、外部工具及需要提供商的 vision 处理不属于
首版后端。图片、音频和视频读取(包括原生内联媒体)也不支持,因为 worker 不接收模型的
modality 配置。不支持的媒体读取会报告该限制,不得静默回退到宿主执行。

View file

@ -0,0 +1,437 @@
# ACP bridge control-plane / harness boundary
[English](2026-09-14-acp-bridge-control-plane-harness-boundary.md) | [简体中文](2026-09-14-acp-bridge-control-plane-harness-boundary.zh-CN.md)
Status: historical design and verification record for the lifecycle-registry,
handshake, transport and startup slices, 2026-09-15. The remaining split is now
implemented; final combined acceptance is tracked in the
[completion design](2026-09-15-acp-bridge-completion.md).
Tracks [#11866](https://github.com/QwenLM/qwen-code/issues/11866). Baseline code
and test observations refer to `9efdd898e7`; local implementation and verification
results are identified separately below.
## Problem and current state
At the baseline, `packages/acp-bridge/src/bridge.ts` contains 15,358 lines. Its
`createAcpSessionBridge` factory occupies roughly 12,585 lines and owns both
session policy and ACP channel supervision. The problem is shared responsibility
and mutable state, so moving the function into several files alone is inadequate.
The public `AcpSessionBridge` interface already exists in `bridgeTypes.ts`.
`ChannelFactory` and `AcpChannel` already exist in `channel.ts`; physical spawning
is implemented in `spawnChannel.ts`. Reuse these seams. `deriveConfig` belongs to
`packages/core/src/config/config.ts`, not this bridge; do not move it here.
Each bridge is bound to one canonical workspace. Sessions multiplex onto its
channel. One channel is available for attachment, but a replacement can coexist
with an old channel awaiting exit. `aliveChannels` deliberately retains the old
channel so synchronous shutdown can still reach it. `byId` is the session map;
it has a different owner and lifetime from this channel registry.
## Goals and scope
- Give channel supervision and session control explicit internal ownership.
- Preserve public bridge methods, ACP messages, HTTP/SSE behavior, errors,
telemetry, ordering, and workspace isolation.
- Deliver independently reviewable PRs with behavioral evidence at each step.
This work does not implement Java serving, remote transport, `wake(sessionId)`,
`getEvents()`, a worker pool, or external event storage. The bridge currently
creates its `EventBus` and compaction engine in the hosting process. Retention
caps do not prove that every idle session consumes that amount of heap, and a
module split alone does not reduce retention.
## Ownership and internal boundary
| Responsibility | Owner after extraction | Boundary rule |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Workspace selection, trust, environment construction | Existing runtime caller | Construct one bridge with the resolved workspace and dependencies; never select a fallback runtime inside the supervisor. |
| Current channel, live channel registry, startup coalescing | Channel supervisor | Keep attach availability distinct from transport/process lifetime. |
| Factory invocation, ACP handshake, liveness and physical teardown | Channel supervisor | Consume existing `ChannelFactory`; report channel outcomes without mutating session policy. |
| `byId`, default attachment, session creation/restore and ownership | Session control | Bind each session to its actual channel; a replacement current channel does not acquire old sessions. |
| Prompt admission/FIFO, mid-turn promotion, terminal deduplication | Session control | Supervision must not own queues, terminal latches, or event publication. |
| Event/journal state, permission settlement, artifact forwarding | Session control and existing modules | Keep current persistence and publication order; do not create another storage or ingestion path. |
| Worktree attribution | Session control plus existing worktree operations | Preserve per-session metadata and transfer barriers; translate the selected directory into ACP requests at dispatch. |
| Idle and retirement policy | Session control supplies eligibility; supervisor performs channel lifecycle actions | Include restore/new-session settlement, runtime reservations, workspace calls and child active-work holds before declaring idle. |
`BridgeClient` currently calls into session state and publication callbacks while
being constructed inside `ensureChannel`. In the completed split, compose that
adapter with the existing session lookup and handlers, and hand the resulting
ACP client to supervision. Do not pass the entire bridge, `byId`, or a shared
all-purpose state object into the supervisor. Keep session cleanup as a named
control-plane handler of channel exit, preserving its ordering relative to
registry cleanup and subscription closure.
The dependency direction is session control → channel operations, with explicit
ACP client and exit handlers supplied during composition. These are internal
TypeScript seams, not a new remote protocol or package export.
## First implementation PR: channel lifecycle registry
Start with the three lifecycle state slots: `channelInfo`, `aliveChannels`, and
`inFlightChannelSpawn`. Encapsulate their mutation in an internal
`channel-lifecycle.ts` module. Keep the existing `ChannelInfo` shape initially;
move its declaration to an internal type module if needed to avoid an import
cycle. Do not move `SessionEntry` or turn this into a generic registry framework.
The internal interface should expose only operations used by existing callers:
| Operation | Required semantics and current consumers |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read the current slot | Preserve the raw slot, including a dying channel; `liveChannelInfo` retains its existing availability filter. Used by ACP callback routing, startup, liveness and idle checks. |
| Read or join the pending startup | Share one startup outcome and clear its slot on settlement, including rejection. Used by `ensureChannel`, `doSpawn` telemetry, runtime status and shutdown. |
| Track, publish and remove a channel | Track before awaiting initialization; publish only at the existing successful-handshake point; remove only on exit. An old exit clears the current slot only if it still names that same channel. |
| Inspect registered channels | Membership and snapshots serve session-owner lookup, settlement/liveness guards, status, and both shutdown paths. Callers cannot add/delete through an exposed mutable set. |
Keep the startup body, runtime epoch allocation, `BridgeClient` construction,
exit side effects, `isDying` transitions, idle timers, and shutdown orchestration
in `bridge.ts` in this first PR. A startup callback may temporarily retain the
existing closure; this is an incremental ownership step, not completion of the
harness boundary. Keep the shutdown check and idle cancellation at their current
positions before startup reuse/coalescing. Preserve synchronous state visibility;
do not introduce an extra asynchronous dispatch hop.
Audit every read and write of the three old variables, including
`channelInfoForEntry`, `doSpawn`'s `reused`/`joined`/`spawned_on_request` telemetry,
`getWorkspaceRuntimeLifecycleSnapshot`, and in-flight shutdown waiting. The
channel registry must not decide admission, quarantine, runtime trust, or session
termination. New-session rejection on a quarantined channel is separate from
whether its existing sessions can continue using that channel.
Expected source scope: `bridge.ts`, the new internal lifecycle module, an internal
channel type file if needed, and collocated/focused tests. No edits to core,
routes, SDKs, package exports, or dependency manifests are expected. Prefer a few
hundred changed production lines; split further if this requires moving the
entire startup function or rewriting its callback wiring.
The first slice now keeps `ChannelInfo` and `createChannelLifecycle` together in
`channel-lifecycle.ts`; a separate type file was unnecessary. Its explicit
`ChannelLifecycle` interface exposes `current`, `starting`, `size`, `has`,
`values`, `track`, `publish`, `remove`, `startSpawn`, and `finishSpawn`. The set
itself stays private; `values` preserves the original live iteration semantics.
`startSpawn` returns the original startup promise synchronously, and the creator
still calls `finishSpawn` from its existing `finally` in `ensureChannel`. Session
cleanup, handshake publication and shutdown retain their original ordering.
## Second implementation slice: handshake protocol
Extract the synchronous ACP initialize request construction and capability
negotiation into internal `channel-handshake.ts`. Its two functions,
`createChannelInitializeRequest` and `negotiateChannelCapabilities`, consume
only the private parent capability, the resolved filesystem-read delegation
boolean, the initialize response and whether external tool enforcement is
required. The result contains negotiated active-work interval/categories and
channel-liveness support. It does not contain session state, the channel registry,
timers or transport objects, and is not exported from the package.
Keep all advertised metadata, protocol/client identifiers, filesystem flags,
required guard acknowledgement errors, version checks, interval clamping and
category filtering unchanged. An absent or unsupported optional capability must
remain unnegotiated; it must not be interpreted as an idle report. The private
parent capability must remain the same value sent to the physical factory for
that channel, including across replacement channels.
Keep the initialize call, remaining startup budget, timeout/transport race,
telemetry span and profile, failure teardown, runtime epoch and publication in
`bridge.ts`. Assign the parsed active-work capability with its initial `seq: 0`
inside the existing span callback, immediately after synchronous negotiation,
before the profiler and before returning the response. Assign liveness support
there too. Adding an asynchronous boundary or moving these assignments outside
the span could drop an early active-work snapshot and is out of scope.
Use the existing guard, private capability, file capability, active-work,
liveness and startup-failure bridge tests as regression evidence. Add focused
bridge-level coverage for unsupported capability versions and negotiation before
the initialize span finishes. Run those tests before the source extraction,
repeat the public HTTP/SSE baseline against global `qwen`, then build, typecheck,
bundle, run affected tests and repeat the E2E harness against the local bundle.
Acceptance is sole ownership of handshake wire construction/interpretation in
the new module, no additional async hop and no changed lifecycle or session
behavior. This slice does not complete startup supervision or the whole issue.
## Third implementation slice: transport operations
Move physical termination and the channel-unavailable race to internal
`channel-transport.ts`. `terminateChannel(channel, timeoutMs, context)` consumes
the existing `AcpChannel` and resolved bridge initialization timeout. It starts
`kill()` immediately, bounds its completion, attempts `killSync()` on failure,
and preserves the original rejection even if the forced signal succeeds. If
both operations fail, preserve their order in `AggregateError`. The bridge
continues to mark channels dying and stop liveness before requesting teardown.
Keep `channelUnavailableReject(channel, context)` synchronous: it returns the
existing race against process exit and, when present, transport failure, mapping
both fulfillment and rejection to `BridgeChannelClosedError`. Do not introduce
another async wrapper or eager subscription. The session and workspace-status
caches remain in the bridge so repeated commands reuse their existing rejection
promise and retain the actual owning channel.
Move the one existing `withTimeout` implementation unchanged into internal
`with-timeout.ts`, imported directly by both modules. Preserve its error type,
timer reference, cleanup in `finally`, and non-cancelling semantics. No timeout
policy or additional fallback is introduced. Keeping this dependency separate
avoids importing session code from transport operations or duplicating the timer
implementation.
Termination consumers are channel kill/idle retirement, pending-empty-channel
reaping, late factory disposal, connection-construction failure, initialize
failure, late shutdown, exit during initialize, invalid runtime epoch, and
bridge shutdown. Unavailability consumers are initialize, new-session creation,
the cached session race, the cached workspace-status race, restore, and
branch-session cleanup. Retain each call's original channel, context and timeout.
No route, registry, session-cleanup handler or package export moves in this slice.
Factory invocation stays in place because its abort controller and startup
budget also govern handshake and epoch failure; extracting it now would require
a broader startup boundary.
Before extraction, add bridge-level tests for a hanging teardown's deadline and
forced signal, combined graceful/forced teardown failure, and rejected transport
failure while a prompt is pending. Existing tests retain successful shutdown,
construction cleanup, overlapping old/new channels, handshake exit, restore,
status and command timeout coverage. Repeat global and local public E2E, build,
typecheck, bundle and focused tests. Acceptance is independent transport
operations with unchanged scheduling, errors, channel ownership and cleanup.
## Fourth implementation slice: startup orchestration
Move the complete asynchronous startup body into internal `channel-startup.ts`.
`createChannelStartup` owns the local runtime epoch and exposes a read-only
`epoch` plus `start`. The lifecycle registry still coalesces its raw startup
promise; `ensureChannel` retains shutdown admission, idle cancellation, reuse,
and the creator's `finally`. Initial epoch validation stays at bridge
construction, and all later epoch consumers read the startup owner's value.
Keep factory invocation, private capability and channel ID generation, the shared
startup clock and abort controller, construction failure cleanup, registration,
initialize, late-shutdown/exit checks, epoch validation, publication and liveness
installation within the same async function. Preserve every existing `await`
position. In particular, do not return from an async factory helper before
registration, and do not return initialized state to the bridge for later
publication. The initialize span still assigns active-work state before profiling
or returning. All existing errors, telemetry, time budgets and late-result
cleanup retain their ordering.
The bridge provides synchronous `constructChannelInfo`,
`handleChannelTransportUnavailable` and `handleChannelExit` actions.
`constructChannelInfo` keeps `BridgeClient`, guarded connection construction,
session sets and active-work callback wiring together; the startup owner retains
the surrounding physical construction-failure catch. The transport action clears
extension refreshes after the startup owner marks failure and stops liveness.
The exit action retains the complete ordered cleanup of timers, registry,
sessions, prompt terminals, attachments and event buses. Startup only registers
that action at the original point; it never receives `byId` or `SessionEntry`.
The existing `killChannelWithLog` action remains the liveness fallback, and
`isShuttingDown` reads live admission state. Other inputs are the already-resolved
factory, workspace, environment snapshot, timeout, telemetry, registry and epoch
source; external-tool-guard presence is still read at negotiation time.
`ChannelLifecycle` is exported from its internal module for this new type
consumer; no package export is added.
This slice moves startup ownership and its epoch, not idle/session policy or
exit cleanup ownership. It adds no public options, routes, package exports or
cross-package dependencies. Add deterministic bridge regressions before moving
production code for the factory/initialize shared deadline, synchronous channel
registration before the initialize span, and shutdown before publication.
Retain the earlier capability-sequencing, shared-startup, late-factory cleanup,
construction-failure and epoch tests. Run the global public E2E baseline, then
build, typecheck, bundle, focused tests and the unchanged local E2E. Acceptance
requires preserved event-loop ordering, correct session/channel ownership and
normal public behavior; the whole issue still includes further control-plane
and channel-exit separation.
## Subsequent PRs and related work
1. Separate channel-owned exit cleanup from session cleanup in the named exit
action, then move it to the channel owner. Move idle scheduling only with its
complete busy and reservation inputs. Expand the internal interface only for
observed consumers.
2. Extract session lifecycle and then prompt admission/FIFO, mid-turn handling,
terminal publication and artifact forwarding in bounded PRs. Retain the
original factory as composition and the public interface as the facade.
3. Update both design versions with actual boundaries and verification evidence
as each slice lands. File length alone is not acceptance evidence.
[#11867](https://github.com/QwenLM/qwen-code/issues/11867) can define and test the
external daemon contract in parallel. Process count and spawn coalescing belong
in bridge/lifecycle tests, not in a cross-implementation wire contract.
[#11868](https://github.com/QwenLM/qwen-code/issues/11868) owns event storage and
retention changes. Share the boundary with
[#11869](https://github.com/QwenLM/qwen-code/issues/11869), but do not make its Java
implementation a prerequisite for the first internal extraction.
## Validation and acceptance
Eight existing tests in `packages/acp-bridge/src/bridge.test.ts` passed during
the investigation (932 skipped out of 940). They cover workspace mismatch,
thread-scope channel reuse, concurrent mixed-scope creation, overlapping-channel
force-kill, same-session FIFO, single-scope spawn coalescing, channel-exit fan-out,
and concurrent active prompts. This is a focused baseline, not full-suite or E2E
certification.
Do not overstate that coverage: the mixed-scope test checks distinct sessions but
does not assert the physical factory count; the single-scope coalescing test can
be satisfied by the outer session-spawn deduplication. Three added tests in
`channel-lifecycle.test.ts` passed before extraction: independent thread-scope
callers share one physical startup and receive two sessions; shared startup
rejection permits a fresh retry; and an old exit preserves the replacement for
new sessions and existing-session prompts. They use controllable promises rather
than timing delays to hold startup and physical teardown open.
| Invariant | Required evidence for lifecycle extraction |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Workspace isolation | A mismatched request fails before another channel is created. |
| Shared startup and recovery | Independent session creates join one physical startup; rejection reaches both and a later call retries. |
| Publication and startup failure | Existing preheat tests for exit during initialize, factory timeout/abort, late factory result disposal, and monotonic runtime epochs remain green. |
| Replacement and shutdown | Old and new channels can overlap; old exit preserves the new current channel; synchronous shutdown reaches both; shutdown during startup cannot publish a late live channel. |
| Session behavior | Same-session FIFO, cross-session concurrency, shared-channel sibling survival and exit fan-out remain unchanged. |
| Idle eligibility | Reuse and timer cancellation, pending restores/new sessions, workspace operations and keep-alive reservations retain their current protections. |
Before production changes, write the executable E2E plan in `.qwen/e2e-tests/`
and dry-run its public create/prompt/SSE/delete and workspace-isolation scenarios
against the global `qwen` CLI. Use deterministic fake channels for internal
startup/exit races. After each source slice, run root build and typecheck, focused
package tests, and the affected E2E scenarios against the local bundle. The final
split also requires the full suite requested by #11866 and two clean self-audit
passes under `AGENTS.md`.
The initial documentation preparation attempted root `npm run build`; it failed
in existing core code because `@jitl/quickjs-singlefile-mjs-release-sync` and
`quickjs-emscripten-core` were unavailable, with consequent implicit-`any` errors.
A separate root `npm run typecheck` also failed with those errors and outdated
workspace declarations: for example, `DAEMON_SUBMITTED_PROMPT_META_KEY` exists
in the bridge source but was absent from its local built declaration. Running
`QWEN_SKIP_PREPARE=true npm ci` restored the locked dependencies without changing
manifests or lockfiles. Full root build and typecheck then passed before the
production extraction.
The global `qwen` 0.23.3 E2E dry-run also passed before extraction: workspace
mismatch returns 400, concurrent creates return distinct sessions, each SSE
receives its own prompt result and exactly one terminal, deletion returns 204,
and the sibling continues prompting after the first session closes. All three
model requests used a local fake endpoint; the daemon exited cleanly without
remaining test processes. The executable plan and report are in
`.qwen/e2e-tests/issue-11866-channel-lifecycle.md`.
After extraction, root build, typecheck and bundle passed, as did ESLint on the
three source/test files and formatting checks. The five affected bridge test
files passed all 965 tests. The same E2E harness passed against the local bundle;
normalized HTTP method/route/status sequences, three prompt terminals, fake model
requests and cleanup matched the global baseline. This validates the first slice;
it does not claim the full repository suite required for the completed split.
The second slice implements the two synchronous handshake functions above in
115 lines. Three added bridge-level tests passed before and after extraction:
unknown numeric and string capability versions remain disabled, and an empty
active-work snapshot received before the initialize span returns retains its
sequence so a later stale snapshot cannot overwrite it. The latter test also
accepts a newer report. A temporary mutation that delayed capability assignment
until after the span caused that test to fail; the mutation was then removed.
An AST comparison confirmed that the initialize request and active-work
interval/category expressions match the pre-extraction source. Root build,
typecheck, bundle, ESLint and all 981 tests in eight affected test files passed.
The second slice's public E2E also passed against the local bundle, matching
the repeated global baseline's HTTP sequence, prompt terminals, model requests
and cleanup. Both comparison runs exited normally with no remaining test
processes. An earlier global run passed the public assertions but exited 1
during shutdown; the report retains that intermittent baseline observation and
does not infer its cause. The separate plan and evidence are recorded in
`.qwen/e2e-tests/issue-11866-channel-handshake.md`.
The third slice's four new bridge-level tests passed before and after
extraction. They cover the exact teardown deadline and forced signal, retention
of that timeout after late completion, synchronous and asynchronous kill
failures combined with a forced-kill failure, and a rejected transport signal
closing a pending prompt before process exit. AST comparison verified the three
extracted function bodies, all nine teardown channel/context/budget inputs, six
unavailability calls and 47 other timeout calls. Root build, typecheck, bundle,
ESLint and all 985 tests in nine affected test files passed.
The third slice's public E2E matched its global baseline for HTTP results,
prompt terminals, model requests and cleanup. Both daemons exited 0; independent
process and TCP checks also confirmed no local test process or listening port
remained, with no shutdown error in the log. The separate plan and evidence are
in `.qwen/e2e-tests/issue-11866-channel-transport.md`. These results validate the
three local slices, not the completed issue or full repository suite.
The fourth slice's three bridge regressions passed before and after extraction:
30 ms spent in the factory leaves 20 ms for initialize from a 50 ms budget;
a microtask scheduled by connection construction can force-kill the registered
channel; shutdown waits for a pending factory without publishing its result or
allocating an epoch. Temporarily inserting an async boundary before registration
makes the second test fail because force-kill cannot find the channel. That
mutation was restored before final verification.
Independent AST comparison preserves the complete exit handler, client and
connection construction, initial channel state and transport error sanitizers.
The full startup body matches after normalizing the synchronous action calls
and live-state readers; the rest of the bridge factory matches after replacing
startup wiring and the four epoch reads. Initial build validation identified a
missing internal type export for the new registry consumer; after adding it,
full build, typecheck, bundle and ESLint passed. All 988 tests across ten affected
test files passed. Existing lifecycle, handshake and transport code/tests remain
unchanged except for that type export.
Independent review found that the shutdown-wait regression's original single
microtask wait could miss an early shutdown completion. The test now waits for
one event-loop turn while the factory stays pending. Removing the startup waiter
makes the original assertion pass but the strengthened assertion fail, confirming
that the revised test detects the missing wait. The production mutation was
restored; this correction changes only the test, and restarts final verification
and the two-pass self-audit.
The fourth slice's initial public E2E matches its global baseline for HTTP results,
prompt terminals, model requests and cleanup. Both daemons exited 0; independent
process and TCP checks confirmed no local test process or listener remained,
with no shutdown error in the log. The report and evidence are in
`.qwen/e2e-tests/issue-11866-channel-startup.md`. These results cover four local
slices, not the completed issue or a full-repository test run.
A later post-review local sanity run passed every public assertion but its
daemon exited 1: the child required SIGKILL after about five seconds and the
daemon reported incomplete shutdown. The harness itself exited 0, and no process
or listener remained. This run is a public-behavior match but a shutdown
mismatch, and is preserved separately in the startup E2E report. Its production
source fingerprints equal the earlier successful local run. The earlier global
handshake baseline also had a similar failure, but neither observation proves a
shared cause or rules out a startup-refactor contribution. A subsequent unchanged global run followed serially by a local run both
matched the original baseline and exited 0, with independent process/port
cleanup checks passing. Those successful reruns do not erase the exit-1 result.
The shutdown cause has not been established and is not claimed fixed by this
extraction.
A subsequent investigation reproduced exit 1 twice in the unmodified global
binary with 32 valid skills in an isolated user configuration, including once
without instrumentation. In the instrumented failure, closing the workspace
watcher during Config shutdown blocked synchronously in macOS FSEvents until
the parent's five-second TERM grace expired. This establishes a native watcher
shutdown failure that does not require the extraction; it does not retrospectively
attribute every earlier failure to the same operation. The separate reproduction
script now fails on a nonzero actual daemon exit. With the existing
`CHOKIDAR_USEPOLLING=1` launch setting, the same fixture passed public checks and
exited 0 against both global and local binaries, with process/port cleanup
confirmed. Polling is a tested mitigation, not a default change or permanent fix.
Production code remains unchanged. Evidence and limitations are recorded in
`.qwen/issues/issue-11866-shutdown.md` and
`.qwen/investigations/issue-11866-shutdown/journal.md`.
Acceptance for the first PR is single ownership of the three lifecycle slots,
no mutable registry escape, no changed observable behavior, and the evidence
above. Acceptance for the completed issue additionally requires that supervision
no longer captures or mutates the session registry, queues, terminal latches or
event stores. No public wire/type change or new behavior is part of either gate.
## Risks and remaining decisions
The main risks are changed callback timing, losing a dying channel, routing an
old session through the replacement channel, dropping shutdown waiters, and
confusing runtime activity with an empty session map. Review those consumers
against the exact source revision for each PR.
Startup now uses explicit synchronous construction and cleanup actions, while
`ChannelInfo` still contains session-owned fields used by the existing policy.
Further exit/idle extraction must separate those responsibilities rather than
treating this transitional type as a final transport interface. It does not
freeze a future JVM-facing ABI. Worktree execution remains session-specific, so
a later harness interface must carry that attribution without rebinding the
workspace.

View file

@ -0,0 +1,344 @@
# ACP bridge 控制面 / harness 边界
[English](2026-09-14-acp-bridge-control-plane-harness-boundary.md) | [简体中文](2026-09-14-acp-bridge-control-plane-harness-boundary.zh-CN.md)
状态:生命周期注册表、握手、传输操作与启动四个切片的历史设计及验证记录,2026-09-15。
剩余拆分现已实现;最终组合验收记录在[完成设计](2026-09-15-acp-bridge-completion.zh-CN.md)中。
对应 [#11866](https://github.com/QwenLM/qwen-code/issues/11866)。代码和测试基线
基于 `9efdd898e7`;本地实现与验证结果在下文单独标明。
## 问题与现状
在基线版本中,`packages/acp-bridge/src/bridge.ts` 有 15,358 行,其中
`createAcpSessionBridge` 工厂约占 12,585 行,同时拥有会话策略和 ACP channel
监管职责。问题在于职责和可变状态交织,仅把函数搬到多个文件并不能解决问题。
公共 `AcpSessionBridge` 接口已在 `bridgeTypes.ts` 中定义。`channel.ts` 已有
`ChannelFactory` 和 `AcpChannel`,物理 spawn 已实现在 `spawnChannel.ts`。
复用这些边界。`deriveConfig` 属于 `packages/core/src/config/config.ts`,
不属于这个 bridge,不应搬到这里。
每个 bridge 绑定一个规范化工作区,多个会话复用其 channel。一个 channel
可供 attach,但替代 channel 可以与等待退出的旧 channel 同时存活。
`aliveChannels` 有意保留旧 channel,让同步 shutdown 仍然能够找到它。
`byId` 是会话表,它与 channel 注册表的所有者和生命周期不同。
## 目标与范围
- 为 channel 监管和会话控制建立明确的内部所有权。
- 保持公共 bridge 方法、ACP 消息、HTTP/SSE 行为、错误、遥测、执行顺序和
工作区隔离不变。
- 按可独立评审的 PR 交付,每一步都有行为验证证据。
本工作不实现 Java 服务、远程传输、`wake(sessionId)`、`getEvents()`、worker
池或外部事件存储。bridge 当前在宿主进程创建 `EventBus` 和 compaction engine。
保留上限不能证明每个空闲会话实际占用相应大小的堆,模块拆分本身也不会减少保留量。
## 所有权与内部边界
| 职责 | 抽取后的所有者 | 边界规则 |
| ---------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| 工作区选择、信任、环境构造 | 现有 runtime 调用方 | 用已解析的工作区和依赖构造单个 bridge;supervisor 内不得选择兜底 runtime。 |
| 当前 channel、存活 channel 注册表、启动合并 | Channel supervisor | 区分 attach 可用性与传输/进程生命周期。 |
| Factory 调用、ACP 握手、存活检测和物理终止 | Channel supervisor | 消费现有 `ChannelFactory`;报告 channel 结果,不修改会话策略。 |
| `byId`、默认 attach、会话创建/恢复和归属 | 会话控制面 | 每个会话绑定实际所属 channel;替代的当前 channel 不接管旧会话。 |
| Prompt admission/FIFO、mid-turn 提升、终态去重 | 会话控制面 | Supervisor 不拥有队列、终态 latch 或事件发布职责。 |
| Event/journal 状态、权限结算、artifact 转发 | 会话控制面及现有模块 | 保留现有持久化和发布顺序;不增加另一条存储或 ingest 路径。 |
| Worktree 归属 | 会话控制面及现有 worktree 操作 | 保留会话级元数据和转移屏障;派发时把所选目录传入 ACP 请求。 |
| Idle 和退役策略 | 会话控制面提供可回收条件;supervisor 执行 channel 生命周期动作 | 判断 idle 前纳入 restore/new-session 结算、runtime reservation、workspace 调用和子进程 active-work hold。 |
`BridgeClient` 当前在 `ensureChannel` 内构造,其回调会访问会话状态并发布事件。
最终拆分中,应在组装处把现有会话查找和处理器接入该适配器,再把得到的 ACP client
交给 supervisor。不要把整个 bridge、`byId` 或通用共享状态对象交给 supervisor。
会话清理应成为具名的控制面 channel-exit 处理器,保留它与注册表清理、订阅关闭之间
的执行顺序。
依赖方向是会话控制面 → channel 操作,同时在组装时显式提供 ACP client 和 exit
处理器。这些都是内部 TypeScript 边界,不是新的远程协议或 package export。
## 首个实现 PR:channel 生命周期注册表
从三个生命周期状态槽开始:`channelInfo`、`aliveChannels` 和
`inFlightChannelSpawn`。在内部 `channel-lifecycle.ts` 模块中封装其修改操作。
初期保持现有 `ChannelInfo` 结构;若避免循环导入需要,可把其声明移至内部类型
模块。不移动 `SessionEntry`,也不把它做成通用注册表框架。
内部接口只提供现有调用方需要的操作:
| 操作 | 必须保持的语义与当前消费者 |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| 读取当前槽位 | 保留原始槽位,包括 dying channel;`liveChannelInfo` 保留现有可用性过滤。消费者包括 ACP 回调路由、启动、存活检测和 idle 检查。 |
| 读取或加入正在进行的启动 | 共享一次启动的结果,并在结算时清空槽位,包括拒绝路径。消费者包括 `ensureChannel`、`doSpawn` 遥测、runtime 状态和 shutdown。 |
| 登记、发布和移除 channel | 在等待初始化前登记;仅在现有握手成功位置发布;仅在 exit 时移除。旧 channel 退出时,只有当前槽位仍指向它才清空槽位。 |
| 检查已登记的 channel | 成员查询和快照供会话所有者查找、结算/存活守卫、状态和两条 shutdown 路径使用。调用方不能通过暴露的可变 set 增删成员。 |
首个 PR 中,启动主体、runtime epoch 分配、`BridgeClient` 构造、exit 副作用、
`isDying` 转换、idle timer 和 shutdown 编排仍留在 `bridge.ts`。启动回调可以
暂时保留现有闭包;这是渐进的所有权抽取,不代表 harness 边界已经完成。shutdown
检查和 idle 取消仍放在启动复用/合并之前的原有位置。保留状态的同步可见性,不增加
额外的异步派发步骤。
逐一审查三个旧变量的所有读写,包括 `channelInfoForEntry`、`doSpawn` 的
`reused`/`joined`/`spawned_on_request` 遥测、
`getWorkspaceRuntimeLifecycleSnapshot` 和 shutdown 对进行中任务的等待。
channel 注册表不得决定 admission、quarantine、runtime trust 或会话终止。
quarantined channel 是否拒绝新会话,与其现有会话能否继续使用该 channel 是两件事。
预期源码范围:`bridge.ts`、新的内部生命周期模块、必要时增加内部 channel 类型
文件,以及同目录/定向测试。预期不改 core、路由、SDK、package exports 或依赖
清单。生产代码改动以几百行为宜;若需要搬走整个启动函数或重写回调接线,应继续拆小。
首个切片现已把 `ChannelInfo` 和 `createChannelLifecycle` 一起放入
`channel-lifecycle.ts`,无需另建类型文件。显式 `ChannelLifecycle` 接口提供
`current`、`starting`、`size`、`has`、`values`、`track`、`publish`、`remove`、
`startSpawn` 和 `finishSpawn`。set 本身保持私有,`values` 保留原有的实时迭代
语义。`startSpawn` 同步返回原始启动 promise,创建者仍在 `ensureChannel` 原有
的 `finally` 中调用 `finishSpawn`。会话清理、握手发布和 shutdown 保留原有顺序。
## 第二个实现切片:握手协议
将同步 ACP initialize 请求构造与能力协商抽取到内部 `channel-handshake.ts`。
其中 `createChannelInitializeRequest` 和 `negotiateChannelCapabilities` 两个函数
只接收私有父进程 capability、已解析的文件读取委托布尔值、initialize 响应,以及
是否要求外部工具执行防护。结果包含协商后的 active-work 间隔/类别和 channel-liveness
支持状态,不包含会话状态、channel 注册表、计时器或传输对象,也不从包中导出。
保持所有声明的元数据、协议/client 标识、文件系统标志、强制 guard 确认错误、版本
检查、间隔限制和类别过滤不变。缺失或不支持的可选能力仍为未协商,不能解释为空闲报告。
私有父进程 capability 必须与传给该 channel 物理 factory 的值一致,替换 channel
时也要保持此对应关系。
initialize 调用、剩余启动预算、超时/传输竞速、遥测 span 和 profile、失败清理、
runtime epoch 及发布继续留在 `bridge.ts`。在原有 span 回调内,同步协商后立即将
解析的 active-work 能力与初始 `seq: 0` 一起赋值,位置仍在 profiler 和返回响应之前。
liveness 支持状态也在此赋值。新增异步边界或把赋值移到 span 外部可能丢失早到的
active-work snapshot,不在本次范围内。
复用已有的 guard、私有 capability、文件能力、active-work、liveness 和启动失败
bridge 测试作为回归证据。补充 bridge 层测试,覆盖不支持的能力版本,以及 initialize
span 完成前已进行协商。在源码抽取前运行这些测试,重新用全局 `qwen` 执行公共
HTTP/SSE 基线,再构建、typecheck、bundle、运行受影响测试并使用本地 bundle 重跑
E2E harness。验收标准是握手 wire 构造/解析由新模块独立拥有,不增加异步跳转,
生命周期和会话行为不变。此切片不代表完成启动监管或整个 Issue。
## 第三个实现切片:传输操作
将物理终止与 channel 不可用竞速移到内部 `channel-transport.ts`。
`terminateChannel(channel, timeoutMs, context)` 接收现有 `AcpChannel` 和已解析的
bridge 初始化超时,立即启动 `kill()` 并限制其完成时间,失败时尝试 `killSync()`。
即使强制信号发送成功,也保留原始拒绝;若两次操作都失败,则保持 `AggregateError`
中的错误顺序。bridge 继续在请求终止前标记 dying 并停止 liveness。
`channelUnavailableReject(channel, context)` 保持为同步函数,返回针对进程退出及
可选传输失败的既有竞速,将 fulfilled 和 rejected 两种结果都映射成
`BridgeChannelClosedError`。不增加异步包装或提前订阅。会话和 workspace-status
缓存仍在 bridge 中,后续命令复用已有的拒绝 promise,并保持实际所属 channel。
把唯一的 `withTimeout` 实现原样移到内部 `with-timeout.ts`,供两个模块直接导入。
保留错误类型、计时器引用、`finally` 清理和不取消底层操作的语义,不引入新的超时
策略或兜底。独立放置该依赖,可以避免传输操作导入会话代码或重复实现计时器。
终止操作的消费者包括 channel kill/idle 退役、pending-empty-channel 回收、迟到
factory 结果清理、连接构造失败、initialize 失败、迟到 shutdown、initialize 期间
退出、无效 runtime epoch,以及 bridge shutdown。不可用竞速的消费者包括
initialize、新会话创建、缓存的会话竞速、缓存的 workspace-status 竞速、restore,
以及 branch-session 清理。保留每处调用原来的 channel、context 和 timeout。
本切片不移动路由、注册表、会话清理处理器或 package export。factory 调用仍在原处,
因为其 abort controller 和启动预算还约束握手及 epoch 失败;现在移动它需要更大的
启动边界。
抽取前补充 bridge 层测试,覆盖挂起终止的 deadline 和强制信号、普通/强制终止同时
失败,以及 prompt 挂起时传输失败 promise 被拒绝。已有测试继续覆盖正常 shutdown、
构造清理、新旧 channel 重叠、握手期间退出、restore、status 和命令超时。重新运行
全局及本地公共 E2E、build、typecheck、bundle 和定向测试。验收标准是传输操作独立,
调度、错误、channel 归属与清理保持不变。
## 第四个实现切片:启动编排
将完整的异步启动主体移到内部 `channel-startup.ts`。
`createChannelStartup` 拥有本地 runtime epoch,仅暴露只读 `epoch` 和 `start`。
生命周期注册表继续合并原始启动 promise;`ensureChannel` 保留 shutdown 准入、
idle 取消、复用,以及创建者的 `finally`。初始 epoch 校验仍在 bridge 构造时执行,
后续 epoch 消费者统一读取启动所有者的值。
把 factory 调用、私有 capability 和 channel ID 生成、共享启动时钟与 abort
controller、构造失败清理、登记、initialize、迟到 shutdown/exit 检查、epoch
校验、发布和 liveness 安装保留在同一个异步函数中。保持每个现有 `await` 的位置。
尤其不能从异步 factory helper 返回后再登记,也不能把初始化结果异步返回 bridge
后再发布。initialize span 仍在 profiling 和返回前赋值 active-work 状态。
所有现有错误、遥测、时间预算和迟到结果清理都保留原有顺序。
bridge 提供同步的 `constructChannelInfo`、
`handleChannelTransportUnavailable` 和 `handleChannelExit` 动作。
`constructChannelInfo` 把 `BridgeClient`、带 guard 的 connection 构造、会话
集合与 active-work 回调接线保留在一起;启动所有者保留外层物理构造失败 catch。
传输动作在启动所有者标记失败并停止 liveness 后清理 extension refresh。
退出动作保留 timer、注册表、会话、prompt 终态、attachments 与事件总线的完整
有序清理。启动仅在原位置注册该动作,不接收 `byId` 或 `SessionEntry`。
现有 `killChannelWithLog` 动作继续作为 liveness 的 fallback,
`isShuttingDown` 读取实时准入状态。其他输入是已解析的 factory、workspace、
环境快照、timeout、telemetry、注册表和 epoch source;外部工具 guard 是否存在
仍在协商时读取。`ChannelLifecycle` 从其内部模块导出,供新增的类型消费者使用;
不增加 package export。
本切片移动启动及其 epoch 的所有权,idle/会话策略和退出清理所有权保持原处。
不新增公共选项、路由、package exports 或跨包依赖。移动生产代码前,补充确定性
bridge 回归,覆盖 factory/initialize 共享 deadline、initialize span 前同步登记
channel,以及发布前发生 shutdown。保留此前的能力协商时序、共享启动、迟到
factory 清理、构造失败和 epoch 测试。先运行全局公共 E2E 基线,再执行 build、
typecheck、bundle、定向测试和不变的本地 E2E。验收要求事件循环顺序、会话与
channel 归属和正常公共行为保持不变;整个 Issue 仍包含后续控制面及 channel-exit
职责拆分。
## 后续 PR 与关联工作
1. 在具名 exit 动作中区分 channel 自身的退出清理与会话清理,再把前者移交
channel 所有者。只有带齐 busy 和 reservation 输入,才能移动 idle 调度。
内部接口只按已观察到的消费者需求扩展。
2. 先抽取会话生命周期,再分批抽取 prompt admission/FIFO、mid-turn 处理、终态
发布和 artifact 转发。保留原工厂作为组装点,公共接口继续作为 facade。
3. 随切片落地,同步更新两种语言的设计,记录实际边界和验证证据。文件行数本身不
构成验收证据。
[#11867](https://github.com/QwenLM/qwen-code/issues/11867) 可以并行定义和测试
daemon 外部契约。进程数量和 spawn 合并属于 bridge/lifecycle 测试,不属于跨实现
的 wire 契约。[#11868](https://github.com/QwenLM/qwen-code/issues/11868) 负责
事件存储和保留策略变化。与 [#11869](https://github.com/QwenLM/qwen-code/issues/11869)
同步边界,但不把其 Java 实现作为首个内部抽取的前置条件。
## 验证与验收
调查期间,`packages/acp-bridge/src/bridge.test.ts` 的 8 个现有测试通过
(共 940 个,跳过 932 个)。覆盖工作区不匹配、thread scope 的 channel 复用、
混合 scope 并发创建、重叠 channel 的强制终止、同会话 FIFO、single scope 启动
合并、channel-exit 扇出和并发活跃 prompt。这是定向基线,不是全量测试或 E2E
认证。
不要夸大这些测试的覆盖:混合 scope 测试检查会话不同,但未断言物理 factory 调用
次数;single scope 合并测试可能仅靠外层会话 spawn 去重就能通过。
`channel-lifecycle.test.ts` 新增的三个测试已在抽取前通过:独立 thread-scope
调用方共享一次物理启动并获得两个会话;共享启动被拒后可重新启动;旧 channel
退出后,替代 channel 仍能接收新会话和现有会话的 prompt。测试用可控 promise
保持启动和物理终止窗口,不依赖时间延迟。
| 不变量 | 生命周期抽取必须提供的证据 |
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| 工作区隔离 | 不匹配的请求在创建另一个 channel 前失败。 |
| 启动合并和恢复 | 独立会话创建加入同一次物理启动;拒绝到达两个调用方;后续调用可以重试。 |
| 发布和启动失败 | 初始化期间退出、factory 超时/abort、迟到 factory 结果清理和 runtime epoch 单调性的现有 preheat 测试继续通过。 |
| 替代和 shutdown | 新旧 channel 可重叠;旧 exit 保留新的当前 channel;同步 shutdown 能找到二者;启动中的 shutdown 不得发布迟到的存活 channel。 |
| 会话行为 | 同会话 FIFO、跨会话并发、共享 channel 的兄弟会话存活和 exit 扇出保持不变。 |
| Idle 条件 | 复用和 timer 取消、pending restore/new session、workspace 操作和 keep-alive reservation 保留现有保护。 |
修改生产代码前,在 `.qwen/e2e-tests/` 写可执行 E2E 计划,针对公共
create/prompt/SSE/delete 和工作区隔离场景,用全局 `qwen` CLI 进行基线 dry-run。
内部启动/退出竞态使用确定性的 fake channel。每个源码切片完成后,在根目录运行
build 和 typecheck、包内定向测试,并针对本地 bundle 运行受影响的 E2E 场景。
最终拆分还须通过 #11866 要求的全量测试,以及 `AGENTS.md` 要求的连续两轮干净
自审。
最初准备文档时尝试了根目录 `npm run build`;它在现有 core 代码中失败,原因是缺少
`@jitl/quickjs-singlefile-mjs-release-sync` 和 `quickjs-emscripten-core`,
并产生了相应的隐式 `any` 错误。另行运行根目录 `npm run typecheck` 也失败,
除上述错误外还有过时的 workspace 声明:例如 `DAEMON_SUBMITTED_PROMPT_META_KEY`
存在于 bridge 源码中,但本地构建声明中没有。实现前先修复依赖安装、重新构建
workspace:运行 `QWEN_SKIP_PREPARE=true npm ci` 恢复锁定的依赖,未修改 manifest
或锁文件。随后根目录完整 build 和 typecheck 均在生产代码抽取前通过。
全局 `qwen` 0.23.3 E2E dry-run 也在抽取前通过:工作区不匹配返回 400,并发创建
返回不同会话,每个 SSE 收到自己 prompt 的结果和恰好一个终态,删除返回 204,
关闭首会话后兄弟会话仍可继续 prompt。三次模型请求全部使用本地 fake endpoint,
daemon 正常退出且没有测试进程残留。可执行计划和报告位于
`.qwen/e2e-tests/issue-11866-channel-lifecycle.md`。
抽取后,根目录 build、typecheck 和 bundle 均通过,三个源码/测试文件的 ESLint
以及格式检查也通过。五个受影响的 bridge 测试文件共 965 个测试全部通过。同一个
E2E harness 在本地 bundle 上通过;归一化后的 HTTP method/route/status 序列、
三个 prompt 终态、fake model 请求和清理结果均与全局基线一致。这些证据验证首个
切片,不代表整个拆分最终要求的全仓库测试已通过。
第二个切片用 115 行实现了上述两个同步握手函数。新增的三个 bridge 层测试在抽取
前后均通过:未知的数字和字符串能力版本保持禁用;initialize span 返回前接收的
空 active-work snapshot 保留其序号,迟到的旧 snapshot 不能覆盖它。后一个测试
还验证更新的报告能被接受。临时把能力赋值推迟到 span 返回后,会使该测试失败;
该临时改动随后已移除。AST 对比确认 initialize 请求和 active-work 间隔/类别
表达式与抽取前源码一致。根目录 build、typecheck、bundle、ESLint,以及八个
受影响测试文件的全部 981 个测试均通过。
第二个切片的公共 E2E 也在本地 bundle 上通过,HTTP 序列、prompt 终态、模型请求
和清理结果与重新执行的全局基线一致。两次对照运行均正常退出,没有测试进程残留。
更早的一次全局运行虽通过公共断言,却在 shutdown 时退出 1;报告保留了这项间歇性
基线观察,不推断其原因。独立计划和证据记录在
`.qwen/e2e-tests/issue-11866-channel-handshake.md`。
第三个切片新增的四个 bridge 层测试在抽取前后均通过,覆盖精确的终止 deadline
和强制信号、底层操作迟到完成后仍保留该超时、同步和异步 kill 失败与强杀失败的
组合,以及传输信号被拒绝后在进程退出前结束挂起的 prompt。AST 对比验证了三个
抽取函数的函数体、全部九处终止调用的 channel/context/budget 输入、六处不可用
检测调用,以及另外 47 处超时调用。根目录 build、typecheck、bundle、ESLint
以及九个受影响测试文件的全部 985 个测试均通过。
第三个切片的公共 E2E 在 HTTP 结果、prompt 终态、模型请求和清理方面均与其全局
基线一致。两个 daemon 都退出 0;独立的进程和 TCP 检查还确认本地测试进程及监听
端口均无残留,日志中没有 shutdown 错误。独立计划和证据位于
`.qwen/e2e-tests/issue-11866-channel-transport.md`。这些结果验证三个本地切片,
不代表整个 Issue 或全仓库测试已完成。
第四个切片新增的三个 bridge 回归在抽取前后均通过:50 ms 预算中 factory 消耗
30 ms 后,initialize 只剩 20 ms;connection 构造安排的微任务能强杀已登记的
channel;shutdown 等待未完成的 factory,并拒绝发布其结果和分配 epoch。
临时在登记前插入异步边界后,第二个测试因为强杀找不到 channel 而失败;最终验证
前已恢复源码。
独立 AST 比较确认完整 exit 处理器、client/connection 构造、channel 初始状态和
传输错误过滤保持一致。将同步动作调用和实时状态读取作对应替换后,完整启动主体
保持一致;替换启动接线及四处 epoch 读取后,bridge 工厂的其余部分也保持一致。
首次构建发现新注册表消费者所需的内部类型尚未导出;补充该导出后,完整 build、
typecheck、bundle 和 ESLint 全部通过。十个受影响测试文件的全部 988 个测试通过。
此前生命周期、握手和传输代码/测试除这一类型导出外均未变化。
独立审查发现,shutdown 等待回归原先只等待一个微任务,可能漏掉提前完成的
shutdown。测试现改为在 factory 仍挂起时等待一个事件循环轮次。移除启动等待后,
旧断言仍通过,新断言则失败,确认加强后的测试能发现漏掉等待的回归。生产代码的
临时改动已恢复;本次修正只改变测试,并重新执行最终验证及两轮自审。
第四个切片最初的公共 E2E 在 HTTP 结果、prompt 终态、模型请求和清理方面均与其全局
基线一致。两个 daemon 都退出 0;独立的进程和 TCP 检查确认本地测试进程及监听
端口无残留,日志中没有 shutdown 错误。报告和证据位于
`.qwen/e2e-tests/issue-11866-channel-startup.md`。这些结果验证四个本地切片,
不代表整个 Issue 或全仓库测试已完成。
后来一次审查后的本地 sanity 虽通过全部公共断言,daemon 却退出 1:子进程在
约五秒后需要 SIGKILL,daemon 报告关闭未完成。harness 本身退出 0,且无进程或
监听端口残留。该运行属于公共行为一致、shutdown 不一致,已在启动 E2E 报告中
单独保留。其生产源码指纹与此前成功的本地运行相同。更早的全局握手基线也曾出现
类似失败,但这些观察既不能证明根因相同,也不能排除启动抽取的影响。随后不改变代码和 harness,串行重跑全局与本地,两次均匹配原基线并退出 0,
独立进程和端口清理检查也通过。这些成功重跑不覆盖退出 1 的记录。关闭异常根因
尚未确定,本次抽取不声称修复它。
后续调查在未修改的全局版本中,使用隔离用户配置下的 32 个合法技能,两次复现
退出 1,其中一次完全没有诊断插桩。插桩失败样本表明,Config shutdown 关闭
workspace 监听器时同步阻塞在 macOS FSEvents 中,直到父进程五秒 TERM 等待
到期。这确认了一种不依赖本次抽取的原生监听器关闭故障,但不能追溯认定所有
早期失败都卡在同一操作。独立复现脚本现会在 daemon 实际非零退出时失败。
使用已有的 `CHOKIDAR_USEPOLLING=1` 启动设置后,同一夹具在全局与本地版本
均通过公共检查并退出 0,进程和端口清理也已确认。轮询是已验证的临时缓解方式,
没有成为默认设置,也不属于永久修复。生产代码保持不变。证据和限制记录在
`.qwen/issues/issue-11866-shutdown.md` 和
`.qwen/investigations/issue-11866-shutdown/journal.md`。
首个 PR 的验收条件是三个生命周期槽位只有一个所有者、不暴露可变注册表、可观察
行为不变,并提供上述证据。整个 issue 的最终验收还要求 supervisor 不再捕获或
修改会话注册表、队列、终态 latch 或事件存储。两级验收都不包含公共 wire/type
变化或新增行为。
## 风险与剩余决策
主要风险是回调时序变化、丢失 dying channel、把旧会话路由到替代 channel、遗漏
shutdown waiter,以及把空会话表误判为 runtime 无工作。每个 PR 都要对照实际
源码版本审查这些消费者。
启动现已使用显式同步的构造与清理动作,但 `ChannelInfo` 仍包含现有策略使用的
会话字段。后续 exit/idle 抽取必须分离这些职责,不能把这一过渡类型视为最终
transport 接口。它不冻结未来面向 JVM 的 ABI。worktree 执行仍是会话级的,
后续 harness 接口必须携带该归属,且不能重新绑定 workspace。

View file

@ -0,0 +1,111 @@
# Cross-session messaging: on by default
[English](2026-09-14-cross-session-messaging-default-on.md) | [简体中文](2026-09-14-cross-session-messaging-default-on.zh-CN.md)
Status: implemented alongside this document.
## Problem
`agents.crossSessionMessaging` has been opt-in since the feature landed.
Every session started with it off: invisible to the other sessions of
the same user, unreachable by them, and unable to address them. That was
the right posture while the pieces that make a reachable session safe
were still arriving, one pull request at a time.
They have arrived. An inbox authenticates every connection; a message
from another session is delivered only when both sessions are in the
same review class and is held for the user otherwise; a repository can
make a session more cautious but never less; a sender that outruns the
session is dropped and told; a program the user trusts presents a token
the user minted. With all of that in place, the switch being off by
default no longer protects anything — it only means the feature is not
there for the people it was built for. A voice front-end that joins
through the SDK's peer endpoint cannot see a session whose user never
found the setting, and two sessions in one repository cannot tell each
other a build finished.
## Design
**The schema default becomes `true`.** Nothing else about the setting
changes: `false` still turns the feature off for that session, restart is
still required, and the workspace may still only tighten.
**An unset key is read as on, in one place.** Merged settings carry only
what some scope actually wrote, so the schema default never reaches the
code as a value — every reader saw `undefined` and compared it to `true`.
The four readers (interactive startup, the ACP agent, `/peers`, and the
inbox-failure notice) now ask one helper, which answers on for `true` and
for `undefined`, and off for `false` and for anything it does not
recognize. The last case is deliberate: a value the reader cannot
interpret must not open a socket.
**The workspace ranking follows.** A workspace value is kept only when it
is stricter than what the user or the platform set, and "what they set"
was compared as if unset meant off. With unset meaning on, a repository's
`false` would have ranked equal to an unset user scope and been dropped
— a repository could never turn messaging off. Unset now ranks with
`true`, so `false` is a tightening and is kept; a workspace `true` repeats
the default and is dropped without a warning.
**The words change with the default.** `/peers` on a session with no inbox
no longer tells the user to enable a setting that is already on; it
distinguishes "turned off in settings" from "the inbox could not start".
The controller-token hint, the `send_message` error, the settings
reference, the SDK guides and the messaging section of the commands page
say what turning the feature _off_ looks like instead of how to turn it
on.
## Trade-offs
**No first-run notice.** A line in the transcript saying "other sessions
can now see this one" would need a per-home marker file to show once, and
would repeat for every scoped home. The documentation and `/peers` carry
the explanation; the review rules hold what another session sends for the
user to decide, with the two exceptions below.
**Two senders are delivered without review, and the default now reaches
them.** A process this session starts inherits its child token and is
delivered as the session's own: that is what lets a hook or a build script
report back, and it is documented as such. Review-class parity compares a
class the sender asserts, and nothing authenticates that assertion, so a
same-user process that can read the registry can claim the receiving
session's own class. Both were already true for anyone who turned
messaging on; on by default extends them to users who never looked at the
setting. Neither widens the boundary — both need code already running as
the same user inside the `0700`/`0600` directories, and such code can do
anything that user can — so they are recorded here, and in the setting's
description, rather than gated. Gating the child-token export on an
explicit opt-in would take a documented hook pattern away from default
users. A user who wants every such message reviewed sets
`agents.crossSessionInbound` to `hold`.
**Windows gets no inbox, and says so only when asked.** Automatic inbox
paths are not available there, and the inbox degrades to "unsupported
platform". Startup stays quiet unless someone wrote `true` in their user
settings or this workspace's — an operator's system-defaults file does not
count. `/peers` answers that messaging is not available on this platform,
as information rather than an error. An ACP process stops trying to bind
after its first hosted session learns this, instead of retrying for every
session. Nothing else changes for those users until named pipes land.
**An unrecognized value is off, not on.** The previous readers treated
anything but `true` as off, and the workspace ranking already counted an
unrecognized value as the strict one. Keeping that for the new default
means a typo cannot open a session by accident.
## Files
- `packages/cli/src/peerMessaging/enabled.ts` — the one reader of the
switch.
- `packages/cli/src/config/settingsSchema.ts` — the default and its
description; the generated JSON schema follows.
- `packages/cli/src/config/settingsUtils.ts` — unset ranks as on.
- `packages/cli/src/ui/startInteractiveUI.tsx`,
`packages/cli/src/acp-integration/acpAgent.ts`,
`packages/cli/src/ui/commands/peers-command.ts` — read through the
helper.
- `packages/cli/src/commands/sessions/controllers.ts`,
`packages/core/src/tools/send-message.ts` — wording.
- `docs/users/features/commands.md`, `docs/users/configuration/settings.md`,
`docs/developers/sdk-typescript.md`, `packages/sdk-typescript/README.md`
— the default, and how to turn it off.

View file

@ -0,0 +1,40 @@
# 跨会话消息:默认开启
[English](2026-09-14-cross-session-messaging-default-on.md) | [简体中文](2026-09-14-cross-session-messaging-default-on.zh-CN.md)
状态:随本文档一起实现。
## 问题
`agents.crossSessionMessaging` 自落地以来一直是 opt-in。每个会话启动时都是关着的:同一用户的其它会话看不见它、联系不到它,它也联系不到别人。在让"可达的会话"变得安全的那些零件还在一个个 PR 地到来时,这是正确的姿态。
现在它们都到了。inbox 对每个连接做认证;来自另一个会话的消息只在两个会话处于同一审查类别时投递,否则 hold 给用户;仓库可以让会话更谨慎,但不能更宽松;发得比会话能接的快的发送方会被丢弃并被告知;用户信任的程序出示用户亲手铸造的令牌。这些都到位之后,默认关闭不再保护任何东西——它只意味着这个功能对它本来要服务的人不存在。通过 SDK peer 端点接入的语音前端看不到一个用户从没找到这个设置的会话;同一仓库里的两个会话也没法互相说一句"构建完成了"。
## 设计
**schema 默认值改为 `true`。** 这个设置的其它方面不变:`false` 仍然关闭该会话的功能,仍然需要重启,工作区仍然只能收紧。
**未设置的键读作开,只在一处。** 合并后的设置只包含某个作用域确实写了的内容,所以 schema 默认值从不会以值的形式到达代码——每个读取点看到的都是 `undefined`,然后拿它和 `true` 比较。四个读取点(交互式启动、ACP agent、`/peers`、inbox 失败提示)现在都问同一个辅助函数:`true` 和 `undefined` 答开,`false` 和任何不认识的值答关。最后一条是刻意的:读取方无法解释的值不能打开 socket。
**工作区排名跟着变。** 工作区的值只在严于用户或平台所设时才保留,而"他们所设"此前是按"未设置即关"来比较的。未设置改为开之后,仓库的 `false` 会和未设置的用户作用域同级而被丢弃——仓库将永远无法关闭消息。现在未设置与 `true` 同级,所以 `false` 是收紧、会保留;工作区的 `true` 只是重复默认值,无警告地丢弃。
**措辞随默认值改。** `/peers` 在没有 inbox 的会话上不再让用户去启用一个已经开着的设置,而是区分"在设置里关掉了"和"inbox 没能启动"。controller 令牌的提示、`send_message` 的错误、设置参考、SDK 指南和 commands 页的消息章节都改成说明"关掉"是什么样子,而不是怎么打开。
## 取舍
**不做首次提示。** 转录里一行"其它会话现在能看到本会话"需要按 home 放标记文件才能只显示一次,而且每个 scoped home 都会再来一次。文档和 `/peers` 承担解释;审查规则会把其它会话发来的内容 hold 给用户决定,下面两种情况除外。
**有两类发送方不经审查就投递,默认开启后它们覆盖到了所有人。** 本会话启动的进程继承它的子进程令牌,会被当作本会话自己的消息投递:hook 或构建脚本能回报结果靠的就是这一点,文档里也写明了。审查类别 parity 比较的是发送方自己声明的类别,而这个声明没有任何认证,所以能读取注册表的同用户进程可以声称自己和接收会话同类。这两点对手动开启过的用户早就成立;默认开启把它们扩展到了从没看过这个设置的用户。两者都没有扩大边界——都需要已经以同一用户身份在 `0700`/`0600` 目录内运行的代码,而这样的代码本来就能做该用户能做的任何事——所以在这里和设置描述里记录下来,而不是加闸。把子进程令牌的导出限制为显式开启,会让默认用户失去一种文档里写明的 hook 用法。想让这类消息全部经过审查的用户,把 `agents.crossSessionInbound` 设为 `hold`。
**Windows 没有 inbox,只在被问到时才说明。** 那里没有自动 inbox 路径,inbox 降级为"平台不支持"。除非有人在自己的用户设置或本工作区设置里写了 `true`,否则启动时不提示——运维方的 system-defaults 文件不算。`/peers` 以普通信息而非错误的形式回答"本平台不支持跨会话消息"。ACP 进程在第一个托管会话得知这一点后就不再尝试绑定,不会每个会话都重试一遍。在命名管道落地之前,这些用户没有其它变化。
**不认识的值是关,不是开。** 此前的读取点把 `true` 之外的一切当作关,工作区排名也已经把不认识的值算作严格的一方。新默认值下保持这一点,意味着一个笔误不会意外打开一个会话。
## 文件
- `packages/cli/src/peerMessaging/enabled.ts` — 开关的唯一读取方。
- `packages/cli/src/config/settingsSchema.ts` — 默认值及描述;生成的 JSON schema 随之更新。
- `packages/cli/src/config/settingsUtils.ts` — 未设置与开同级。
- `packages/cli/src/ui/startInteractiveUI.tsx`、`packages/cli/src/acp-integration/acpAgent.ts`、`packages/cli/src/ui/commands/peers-command.ts` — 通过辅助函数读取。
- `packages/cli/src/commands/sessions/controllers.ts`、`packages/core/src/tools/send-message.ts` — 措辞。
- `docs/users/features/commands.md`、`docs/users/configuration/settings.md`、`docs/developers/sdk-typescript.md`、`packages/sdk-typescript/README.md` — 默认值,以及如何关闭。

View file

@ -0,0 +1,48 @@
# Extension workflow distribution
[中文](./2026-09-14-extension-workflow-distribution.zh-CN.md)
Tracking issue: #11013, item 12 (distribution half); #8105 PR 15.
## Problem
Saved workflows could only live in a project (`.qwen/workflows`) or in the user's home (`~/.qwen/workflows`), so there was no way to hand a reusable workflow to someone else. Claude Code plugins already ship workflows through a `workflows` manifest field and a default `workflows/` directory. Extensions are Qwen Code's distribution unit, but extension files are third-party code, so the feature has to widen distribution without widening what a workflow run can read.
## Decisions
### A third saved-workflow tier
Active extensions contribute a third tier to `workflow-saved.ts`, the module that already owns project and user workflows. Every consumer — the `/<name>` slash commands, `workflow('<name>')` inside a script, and the ACP saved-workflow list, detail and run-saved surfaces — reads that one module, so none of them grew a separate extension path.
An extension workflow is always addressed as `<extension name>:<meta.name>`, the same shape as extension skills and Claude Code plugin workflows. The name comes from the script's static `export const meta`, so a file name may differ. Project and user names are file stems matching `^[a-z][a-z0-9-]{0,40}$`, which cannot contain `:`, so the tiers cannot shadow each other; precedence is still written project over user over extension. If one extension ships a skill and a workflow with the same name, `CommandService` renames the workflow command to `<extension>.<name>`, as it does for any colliding extension command, and the command keeps its documented name for `slashCommands.disabled` matching: the skill stays on every surface, including headless, ACP and the model's command list, and one denylist entry written with the documented name removes both. A user or project custom command of the same name loads last and keeps the slash command; the workflow stays reachable through `workflow()`, the ACP saved-workflow surfaces and the web-shell Workflows page.
### Discovery
`workflow-extension.ts` reads the default `workflows/` directory, or exactly the directories and `.js` files the manifest lists (`null` means undeclared). It reads one directory level, refuses paths that resolve outside the extension, refuses symlinks, caps each file at 256 KiB, and parses `meta` with the existing static parser without executing the script. `meta.description` is shortened to 500 characters because it reaches the consent prompt, the command list and the approval dialog. A bad file, or a declared directory that cannot be read, is skipped with a warning and never fails the extension load or drops the paths declared after it.
### The read boundary
A workflow `{ scriptPath }` load is checked by `readWorkflowFileSecurely`, which proves a file sits under a trusted root but does not check that it is a workflow script. Extension directories are therefore **not** added as roots: a root is a grant over every file beneath it, and an extension declaring `"workflows": "."` would expose its `.env` settings file. Instead, a discovered extension file is readable by its exact real path, recorded at load. A file swapped for a symlink after load resolves elsewhere and stops matching. Only `getActiveExtensions()` contributes, so disabling an extension removes its workflows from every surface at once.
### Consent
The install and update consent prompt lists each workflow's name and description, and an update re-prompts when that list changes. Consent discovery resolves environment variables in `workflows` the way loading does. A copied install replaces each symlink with the file it points to, so consent discovery for a copied install follows symlinks and checks containment on the path as spelled; a linked extension loads its source as-is, so its consent uses the runtime rules. Like skill and subagent discovery, consent reads through a symlink whose target lies outside the package, because the copy materializes that target; whether copying should skip such links for every resource type is a repository-wide question left to a follow-up. A change to a script's code alone does not re-prompt, and a path-scoped "always allow" survives an update; both are documented.
### Surfaces and gates
Extension workflows reuse the existing gates: the Workflows feature flag, bare mode and folder trust for slash commands, and the ACP workflow-control check. The slash command carries the extension's owner label as its source badge and is not model-invocable. The Workflow tool's description, parameter description, approval dialog and resume advice name an extension workflow as such. `/reload-plugins`, `qwen extensions list` and the extension detail views report what the extension ships, like their command, skill and agent siblings.
### Claude Code plugin conversion
The converter copies declared workflow files at their relative paths and lists them in the converted manifest, so same-named files in different directories stay distinct. Only the listed files are discovered; a plugin that declares no `workflows` keeps its `workflows/` directory as the default. A marketplace entry's `workflows` overrides the plugin's own. A symlink inside a declared directory is copied as a regular file when its target stays inside the plugin. Agent Plugins v1 packages contribute no workflows, because that schema defines none.
### Published contract
The saved-workflow `source` union in the ACP bridge status types and the TypeScript daemon SDK gains `'extension'`. `listSavedWorkflows` returns extension entries, so the runtime already emits that value on those surfaces; keeping the published type narrower than the data would mislead consumers. Consumers with an exhaustive switch over `'project' | 'user'` need a third branch.
## Not in scope
- `Workflow({ name, args })` invocation and per-name `Workflow(name:...)` permission rules that can pin a script's content.
- A bundled workflow tier and a name-only lock.
- Workflow counts in the serve extension capability payload.
- Running workflow slash commands in headless or ACP modes.

View file

@ -0,0 +1,48 @@
# 扩展分发 workflow
[English](./2026-09-14-extension-workflow-distribution.md)
跟踪 issue:#11013 第 12 项(分发部分);#8105 PR 15。
## 问题
saved workflow 只能放在项目(`.qwen/workflows`)或用户目录(`~/.qwen/workflows`),没有办法把可复用的 workflow 分发给别人。Claude Code 插件已经通过 `workflows` 清单字段和默认的 `workflows/` 目录分发 workflow。扩展是 Qwen Code 的分发单元,但扩展文件是第三方代码,所以这个功能必须在扩大分发范围的同时,不扩大 workflow 运行时能读取的范围。
## 决策
### 第三个 saved workflow 层级
已启用的扩展为 `workflow-saved.ts` 提供第三个层级,这个模块本来就负责项目和用户 workflow。所有消费方 —— `/<name>` 斜杠命令、脚本内的 `workflow('<name>')`、ACP 的 saved workflow 列表、详情与 run-saved 接口 —— 都只读这一个模块,因此没有任何一方长出单独的扩展路径。
扩展 workflow 始终以 `<扩展名>:<meta.name>` 寻址,与扩展 skill 和 Claude Code 插件 workflow 的形态一致。名字取自脚本静态的 `export const meta`,所以文件名可以不同。项目和用户层的名字是匹配 `^[a-z][a-z0-9-]{0,40}$` 的文件名,不可能含 `:`,因此各层不会互相遮蔽;优先级仍写为项目优先于用户、用户优先于扩展。如果同一个扩展同时提供同名的 skill 和 workflow,`CommandService` 会像处理任何重名的扩展命令一样,把 workflow 命令改名为 `<扩展名>.<名字>`,同时该命令保留文档中的名字用于 `slashCommands.disabled` 匹配:skill 在所有入口(包括 headless、ACP 和模型可见的命令列表)都保持可用,按文档名写下的一条禁用条目就能同时移除两者。同名的用户或项目自定义命令最后加载,会保留该斜杠命令;此时 workflow 仍可通过 `workflow()`、ACP saved workflow 接口和 web-shell Workflows 页面访问。
### 发现
`workflow-extension.ts` 读取默认的 `workflows/` 目录,或者只读取清单列出的目录和 `.js` 文件(`null` 视为未声明)。它只读一层目录,拒绝解析到扩展目录之外的路径,拒绝符号链接,单个文件上限 256 KiB,并用现有的静态解析器解析 `meta`,不执行脚本。`meta.description` 截断到 500 个字符,因为它会出现在同意提示、命令列表和审批对话框里。坏文件或无法读取的声明目录会带警告跳过,既不会让扩展加载失败,也不会丢掉排在它后面声明的路径。
### 读取边界
workflow 的 `{ scriptPath }` 加载由 `readWorkflowFileSecurely` 检查,它只证明文件位于某个受信任的根目录下,不检查文件是不是 workflow 脚本。因此扩展目录**不**作为根目录加入:一个根目录等于授权其下的所有文件,声明了 `"workflows": "."` 的扩展就会暴露自己的 `.env` 设置文件。取而代之的是,已发现的扩展文件只能按加载时记录的精确真实路径读取。加载后被替换成符号链接的文件会解析到别处,从而不再匹配。只有 `getActiveExtensions()` 会提供 workflow,所以停用扩展会让它的 workflow 从所有入口同时消失。
### 同意
安装和更新时的同意提示会列出每个 workflow 的名字和描述,列表变化时更新会重新询问。同意阶段的发现会像加载时一样展开 `workflows` 里的环境变量。复制式安装会把每个符号链接替换为它指向的文件,所以复制式安装的同意阶段会跟随符号链接,并按书写的路径检查包含关系;link 扩展直接加载源目录,所以它的同意阶段沿用运行时规则。与 skill 和 subagent 的发现一致,同意阶段会跟随目标在包外的符号链接读取内容,因为复制会把该目标实体化;复制时是否应对所有资源类型跳过这类链接,是仓库范围的问题,留给后续跟进。只修改脚本代码不会重新询问,按路径授予的"始终允许"在更新后仍然有效;这两点都写进了文档。
### 入口与门禁
扩展 workflow 复用现有门禁:斜杠命令受 Workflows 功能开关、bare mode 和 folder trust 约束,ACP 受 workflow 控制检查约束。斜杠命令以扩展的归属标签作为来源徽标,并且不能被模型调用。Workflow 工具的描述、参数描述、审批对话框和 resume 建议都会把扩展 workflow 标明为扩展 workflow。`/reload-plugins`、`qwen extensions list` 和扩展详情视图报告的是扩展提供了什么,与它们对命令、skill、agent 的做法一致。
### Claude Code 插件转换
转换器会按原相对路径复制声明的 workflow 文件,并把它们列进转换后的清单,所以不同目录下的同名文件互不覆盖。只有清单列出的文件会被发现;未声明 `workflows` 的插件保留其 `workflows/` 目录作为默认目录。marketplace 条目的 `workflows` 会覆盖插件自身的声明。声明目录里的符号链接,若目标仍在插件内,会被复制成普通文件。Agent Plugins v1 包不提供 workflow,因为该规范没有定义这个字段。
### 公开契约
ACP bridge 状态类型和 TypeScript daemon SDK 中 saved workflow 的 `source` 联合类型新增 `'extension'`。`listSavedWorkflows` 会返回扩展条目,运行时在这些接口上已经会带出这个值;让公开类型比实际数据更窄只会误导调用方。对 `'project' | 'user'` 做穷尽判断的调用方需要增加第三个分支。
## 不在范围内
- `Workflow({ name, args })` 调用,以及能锁定脚本内容的按名 `Workflow(name:...)` 权限规则。
- 内置 workflow 层级与仅按名锁定。
- serve 扩展能力数据中的 workflow 计数。
- 在 headless 或 ACP 模式下运行 workflow 斜杠命令。

View file

@ -0,0 +1,614 @@
# Complete the ACP control-plane / harness boundary
[English](2026-09-15-acp-bridge-completion.md) | [简体中文](2026-09-15-acp-bridge-completion.zh-CN.md)
Status: merged in [#11916](https://github.com/QwenLM/qwen-code/pull/11916);
the configured Linux CI suite passed on the merged commit, 2026-09-16.
See [post-merge acceptance](#post-merge-acceptance) for evidence and limits.
Implements the remaining boundary extraction for
[#11866](https://github.com/QwenLM/qwen-code/issues/11866) after the four
[earlier slices](2026-09-14-acp-bridge-control-plane-harness-boundary.md).
## Problem and scope
After the first four slices, the bridge still owned session policy and physical channel supervision
in one closure. The existing lifecycle, handshake, transport and startup modules
are useful building blocks, but startup's channel type still includes session
state, while physical exit and idle scheduling still mutate it directly.
Finish with two main owners. Keep session registry, admission/FIFO, mid-turn
promotion, terminal deduplication and artifact forwarding together in
`session-control-plane.ts`. Put physical channel ownership and execution in
`channel-harness.ts`, reusing the existing four modules. Keep `bridge.ts` as the
synchronous public composition point and compatibility exports. A cohesive
control-plane module can remain large; line count is not the acceptance test.
This is a behavior-preserving TypeScript extraction. `deriveConfig` stays in
core. No new public options, package exports, daemon routes, wire fields, JVM
implementation, event store or stateless harness API are part of this work.
The watcher, process-query and EOF shutdown fixes have separate evidence and
include intentional behavior changes. In particular, failed EOF draining now
reports exit 1 where the measured baseline exited 0 despite truncation or EPIPE.
The drain covers frames forwarded to the inner stdout writer, not calls still
queued upstream; those can be omitted with an SDK error log and exit 0. See the
[EOF design](2026-09-15-acp-eof-output.md) for the exact boundary and measurements.
## State and construction boundary
`HarnessChannel` holds the physical channel, connection, identity, transport
failure/liveness facts, handshake state, active-work negotiation and resource
samples. A control-plane-private `ChannelInfo` holds its `BridgeClient`, session
ids, pending operations, settlement timers, quarantine and workspace policy.
It references its physical handle. Immutable identity/channel/connection aliases
may be retained; mutable physical fields are accessed through that handle.
A private `WeakMap<HarnessChannel, ChannelInfo>` associates each pair once during
synchronous construction. It is not a second live registry and is never iterated
or synchronized on teardown. The harness alone owns current/alive/starting
membership. An old session or exit callback keeps its original association even
after a replacement becomes current. No getter proxy or mutable registry escape.
The control plane supplies one synchronous construction action: create the ACP
client and guarded connection, create both associated objects, bind callbacks
and return the physical handle. Startup immediately registers it before its
existing initialize await. No extra asynchronous wrapper is introduced around
construction, registration, capability publication or existing ensure calls.
The harness imports ACP client contracts, never `BridgeClient` or session state.
## Lifecycle and execution ownership
| Responsibility | Owner and boundary |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Physical current/alive/starting state and runtime epoch | Harness; preserve raw slot and old/new overlap semantics |
| Idle timer, keep-alive and runtime reservations | Harness; synchronously read current control-plane busy/reap facts |
| Session reaper, close grace, queues and terminal events | Control plane; keep their atomic transitions together |
| Quarantine and abandoned-request settlement policy | Control plane; refuse fresh sessions without disabling existing siblings |
| ACP client routing and session-owned cleanup | Control plane callbacks at their original synchronous positions |
| Guarded connection, process kill and physical shell execution | Harness; preserve errors and direct promise/callback ordering |
| Worktree metadata and transfer/reset barriers | Control plane; pass the selected session cwd and request attribution explicitly |
Idle eligibility includes outer restores, channel-local pending creates/restores,
workspace work and runtime reservations. Preserve the exception that condemned
channels can retire after session work drains. Timer callbacks read current facts.
Reservation acquisition remains synchronous and release retains its original
reap/idle awaits. Do not add a generic RPC dispatch facade over the existing SDK.
One workspace can contain multiple session worktrees on one child. Never rebind
the channel cwd to a worktree. Move the existing worktree defer metadata, session
cwd request and physical shell execution into harness operations; preserve request
spread precedence, trace injection, actual owning channel, and the existing cwd
queue. The control plane retains authorization, reset barriers, result validation,
metadata updates and user-facing event/history publication. Shell cancellation and
deadlines also remain with the session control plane; the harness synchronously
returns the physical execution promise so the original two awaits stay in place.
## Exit, shutdown and composition order
On physical exit, stop liveness, clear transport refreshes, cancel the current
channel's idle timer, run control-plane timer/release cleanup, remove the physical
handle, then run control-plane session cleanup. Preserve this exact order and the
existing per-session terminal-before-bus-close sequence. A late old exit must not
clear the replacement. No asynchronous event emitter is introduced.
The control plane keeps shutdown admission and shared shutdown-promise identity.
Harness snapshot/mark, terminate and killSync operations run at their existing
positions. Keep the single combined wait over physical teardown, startup, session
spawns, restores and abandoned settlements. Do not start killing before session
terminals are published or remove handles before actual exit.
Bridge construction supplies a synchronous harness factory to the control plane
at the existing initialization site. Preserve option-validation order, journal
registration, session-reaper startup and prompt-handler binding. Keep public
error helpers and `createHttpAcpBridge` alias available through the same module.
The internal seam is concrete TypeScript; it does not freeze a future Java ABI.
## Implementation and verification
1. Separate physical and control-plane channel types and their one-time binding.
2. Transfer complete idle/reservation/exit/shutdown ownership and execution helpers
into the harness, preserving existing function bodies and await positions.
3. Move the remaining cohesive control plane and make bridge the composition point.
4. Verify source movement and exported/wire expressions mechanically, then run
build, typecheck, bundle, affected tests, public E2E and the full configured
repository unit suite plus script tests. Follow with two clean audits and
independent review.
Retain all existing workspace-isolation, spawn-coalescing, startup-failure,
old/new-channel overlap, same-session FIFO, cross-session concurrency, exit fan-out,
idle/preheat, restore, terminal, artifact and worktree/reset tests. Add deterministic
coverage for boundary gaps, including synchronous binding and exit release ordering.
The E2E plan is `.qwen/e2e-tests/issue-11866-completion.md`; it reuses established
global public baselines and checks actual process exit and independent cleanup.
Acceptance requires explicit separate owners, no session registry/queues/events in
harness, no physical lifecycle state in bridge, preserved public/wire behavior,
passing verification and resolved review findings. Historical failures remain in
their reports until evidence establishes their outcome.
## Pre-rebase boundary and evidence
This section records the pre-rebase local commit
`88a5eb2bce7fc1dd3e932176dfd8d08ff82afe97`, based on `9efdd898e7`.
As checked on 2026-09-16, that object still exists in the author's local Git
object database, but is not an ancestor of published `b8124075c6` and GitHub's
commit API returns 422 for it. Its counts and test results are retained as
historical author evidence, not a publicly resolvable candidate or current
verification. In particular, the 14,267-line, 2,092-test and 44-file counts below
belong only to that pre-rebase candidate.
Before integration with updated main, the public bridge was 23 lines of synchronous composition and compatibility
exports. The 14,267-line control plane keeps the cohesive session policy closure;
the 444-line harness owns physical lifecycle and execution, reusing the extracted
lifecycle, startup, handshake, transport and connection helpers. Public package
exports are unchanged, strict TypeScript export types match the baseline, and
`createHttpAcpBridge` remains the same function object as the public factory.
Mechanical comparison verifies the complete final control-plane move against
the prior stage, 13 lifecycle bodies, the connection declarations, preservation
of the original 27 channel initializers across both new objects, and
declaration-resolved physical property accesses. A separate
comparison matches 306 surviving name-keyed control-plane bodies; this is not a
claim about every function because duplicate local names exist. Eight changed
wiring bodies were reviewed directly, including synchronous construction, exit,
cwd, shell and shutdown. The historical moved-body inventory contains 270 awaits; it is not a count of
all await tokens across the final modules. Per-body and wiring checks, rather
than the count alone, establish ordering. The added regression test rejects
moving physical-handle removal after the session lifecycle callback; this
historical mutation did not test all six exit steps.
Build, workspace typecheck, lint, formatting, core subpath exports and the serve
bundle boundary passed. The complete ACP bridge suite passed 2,092 tests. Two
clean self-audits and an independent review of the frozen 44-file candidate found
no outstanding source defect. Their evidence is retained under
`.qwen/investigations/issue-11866-completion/`.
Eight serial final-bundle E2E samples passed their acceptance conditions: public
lifecycle, native 32-skill watching, two active writer sessions, two direct EOF
cases, stalled-reader and EPIPE negative cases, and real process-query timeout/
nonzero-exit negatives. The first two public flows match the installed-CLI public
baselines; active-writer shutdown matches the earlier local baseline. Both
positive direct EOF cases measure complete stdout and actual ACP exit 0; the
two output negative cases measure nonzero exit with timeout or EPIPE. Daemon
child cleanup is independently checked without inferring a child exit code.
The same 1,079-file bundle manifest was present before and after E2E.
Multi-second DELETE stalls remain measured observations; no latency equivalence
is claimed. The separate watcher, query-output and EOF designs retain historical
failures and the limits of each repair.
## Pre-rebase repository-wide results and limits
The full workspace command completed all 22 configured workspaces: 75,691 tests
passed, 25 failed and 117 were skipped. The failures were CLI 19, core 5 and Web
Shell 1. A missing installed Ink patch was restored from the lockfile-verified
package and the unchanged repository patch; its three loop-guard tests then
passed. Some original tests also read actual user settings, usage and memories.
The same 25 assertions in isolated child environments and one worker produced
16 passes and 9 failures. This does not replace the failed full-suite result or
identify one cause for every failure.
The three cron assertions also failed with the exact HEAD scheduler loaded at
its original module ID. The logger deadline assertion failed with the observed
changed source/dist dependencies replaced by HEAD equivalents. The metrics race
passed in both later paired arms. These are bounded module comparisons, not a
whole-tree HEAD run. Separate diagnostics recorded an unchanged AuthDialog state
race, a Git-version-dependent assertion and a passing AddMenu focus observation;
the original failures and unobserved native-watch/shim causes remain recorded.
The source assertions and deadlines were not changed to obtain green results.
| Additional verification | Result |
| ----------------------- | ------------------------------------------------ |
| Complete script suite | 87 files; 2,412 passed, 75 skipped; exit 0 |
| 27 CI helper files | 528 passed, 42 failed, 4 skipped; exit 1 |
| Python SDK | 173 passed with isolated Python 3.12 environment |
| Mobile unit tests | 43 passed with the matching Playwright runner |
| Channel plugin example | 4 passed |
All 42 CI helper failures are in the unchanged Linux triage wrapper tests (39
gate and 3 staging cases). One unchanged all-pass assertion was observed directly:
on macOS the GNU stat/proc inode guard refuses before any sampling round, with
zero runner invocations and no gate log yet created. The staging log also records
the unavailable `/usr/bin/rm` path. No Docker command was available on the current
PATH, and this run did not validate Linux CI. The failed results remain visible;
they are not described as passing CI.
The restored Ink dependency changed only its box-metrics hook. A fresh complete
build, typecheck, bundle, serve bundle boundary and core export checks all passed.
The new 1,079-file bundle is frozen in `post-install-bundle-manifest.json`. All
eight public E2E scenarios also met their original acceptance conditions against
this rebuilt artifact, whose complete manifest matched before and after. Both
positive EOF cases delivered 1,062,289 stdout bytes and actual ACP exit 0;
the negative cases retained actual exit 1 with the original timeout and EPIPE.
All owned processes and listeners were gone. The normalized positive output
matches the earlier final bundle; prior failures are preserved separately.
Full-suite acceptance remained open at this stage because the repository-wide
command was not green. Detailed evidence and separate
follow-ups are retained in `.qwen/investigations/issue-11866-completion/` and
`.qwen/issues/issue-11866-unrelated-unit-followups.md`.
## First published candidate: main integration
The first published candidate, `768e1b194f`, integrates main at
`d47a8fdbae49d6acd4b7b4fb2eb650200e2ae9c8`, 16 commits after the original
baseline. Five non-document candidate files change relative to the pre-rebase
candidate.
The bridge remains 23 lines, the control plane is now 14,465 lines, and the
harness remains 444 lines. Updated-main background-turn admission, terminal
waiting, cancellation epochs, active-work snapshots, restore metadata and busy
guards remain in the control plane. Upstream workspace hooks and extension
workflow watching are retained.
The three conflict regions were resolved at the extraction boundary: imports,
physical active-work storage and synchronous client construction. Independent
open-ended and presume-wrong source audits rechecked the complete integration
delta and its downstream consumers. All 25 client-construction arguments match
updated main after the declared physical-access correspondence. A separate
comparison matches 305 surviving name-keyed bodies; another retains duplicate
name occurrences and checks all 20 upstream-changed named bodies. Neither is a
whole-program proof. Non-mechanical construction, exit, shutdown, cwd and shell
wiring were read directly. The new await inventory is 271, not the old 270;
ordering evidence comes from the actual statements and callbacks.
Fresh build, workspace typecheck, lint, bundle, core subpath exports and the
serve bundle boundary passed. Rebased unit results are recorded separately:
| Verification | Result |
| ---------------------------------------- | ------------------------------------------------------------------- |
| Complete ACP bridge suite | 42 files; 2,130 passed |
| 11 affected CLI files | 1,905 passed, 2 failed in the isolation wrapper |
| Two CLI directory assertions, diagnostic | Both passed after removing the wrapper's runtime-directory override |
| Three affected core files | 228 passed, 9 failed in the isolation wrapper |
| Six core skill assertions, diagnostic | All passed after removing the wrapper's Qwen-home override |
| Serve bundle guard tests | 40 passed |
The two CLI failures received the wrapper's `QWEN_RUNTIME_DIR` instead of the
directory installed by each test. The unchanged main implementation explicitly
prioritizes that environment variable. Reusing the same isolated home/settings
and removing only the conflicting override makes the two original assertions
pass. This is a bounded diagnostic, not a replacement all-green 1,907-test run.
Six core failures similarly used the wrapper's `QWEN_HOME` instead of the
mocked home directory where those tests provide their user-skill fixtures.
Removing only that override, while retaining the same isolated home, runtime
and system settings, makes all six original assertions pass. The original
228-pass/9-failure run remains recorded separately from this diagnostic.
Three core failures remain recorded in durable cron watching: no observed
watcher read after 600 ms, an afterEach cleanup hook exceeding 10 seconds, and
an external task not observed within 3 seconds. The current hook timeout is
not relabeled as the historical assertion failure. Prior baseline comparisons
remain historical evidence; they do not establish the cause of every current
failure. No product code, assertion or deadline was changed to make these
results green. Full-suite acceptance therefore remained open at this stage.
All eight serial E2E scenarios met their positive or negative acceptance
conditions on the rebuilt candidate. Public lifecycle, native watching with
32 skills and active-writer shutdown measured daemon exit 0 and independent
cleanup. Both writer seals match their actual 3,609/3,604-byte transcripts and
hashes. Both positive EOF cases delivered 1,072,333 stdout bytes, including the
complete 524,288-byte fixture, and measured actual ACP exit 0. Stalled-reader
and closed-pipe cases retain harness FAIL/exit 1 and actual ACP exit 1 with the
original 2,000 ms drain error or EPIPE. The query-negative fallback measured
SIGTERM at 2,002 ms with both pipes undestroyed; timeout and separate exit 7
both report snapshot failure despite the owned processes exiting cleanly.
The two new positive EOF outputs match after normalizing only exact temporary
roots and session UUIDs. The historical comparison remains MISMATCH: the CLI
version and two bundled skill bodies differ. Those bodies match the updated
upstream content byte-for-byte and account for the 10,044 additional bytes.
The lifecycle sample also captured an existing command-catalog notification
present in the installed-CLI sample but absent from the previous local sample.
Public subset equality is not complete wire equality, and these ordinary-prompt
fixtures do not exhaustively test tool-driven background turns.
The complete 1,079-file artifact manifest matches before and after E2E, as does
the separately imported process-registry build. CLI SHA-256 is
`5e3d6af2d543ffed89610eec76248be54badd740a9b5d106d56ad68a116adef5`;
the full dist tree digest is
`8e9ce5cfc6a2575c6679566a3675726e0d58f57accaf80ac10aafb01ce13fd2d`.
All owned processes and listeners were independently checked. Actual direct
ACP exit measurements remain separate from daemon-child absence. Rebase
evidence is indexed by `rebase-e2e-manifest.json` in the completion investigation
directory; source audits and the rebuilt artifact manifest are under
`.qwen/pr-reviews/`. Historical failures and their limits remain above.
## Follow-up integration with channel output modes
The evidence in this section was gathered for `f0153063d2`.
After the first publication, main advanced to
`473ef4b3e474ddc16d7bd6db32fcc86185a9cac6`. Its bridge change conflicted with
the extraction. The six added lines are preserved at the same synchronous
control-plane request-building point: capture the supplied output mode, always
strip it, then restore only `per_task` for trusted channel-prompt context.
The ACP agent retains its independent trusted-parent check. No mode policy or
session capture state moves into the physical harness.
The merge also retains upstream's session-owned permission queue and complete
channel-task capture, queue, cancellation, disposal and result handling. Related
task notifications remain part of their waiting RPC instead of waiting on that
same RPC through independent background admission. The bridge remains 23 lines,
the control plane becomes 14,471 lines, and the harness remains 444 lines.
Two independent open-ended and reverse source audits found no introduced
defect. Only three non-document candidate files differ from `768e1b194f`;
their additions match upstream's six bridge lines, six ACP lines and 42 test
lines. All other non-candidate files match the new main exactly. Complete
request construction and ACP prompt handling were checked, not only line counts.
Fresh build, workspace typecheck, lint, bundle, core exports and serve bundle
boundary checks passed, followed by all 2,130 ACP bridge tests.
All 2,170 tests in 15 affected CLI files also passed, including the complete
Session suite, trusted/forged channel-mode filtering and channel configuration.
These commands retain isolated HOME and system settings while explicitly
unsetting the wrapper's `QWEN_RUNTIME_DIR` and `QWEN_HOME`, allowing each test
to supply its own runtime and mocked-home fixtures. Original assertions and
deadlines are unchanged; the earlier failed wrapper runs remain above.
The eight related channel-base files passed all 1,150 tests, including both
channel bridges, session routing, output modes, output turns and background
output coordination. Together with the CLI tests, these checks cover trusted
`per_task` and permission behavior that the eight ordinary-prompt process
scenarios do not exercise.
All eight fresh serial E2E scenarios met their acceptance conditions. Public
lifecycle, native watching with 32 skills and active-writer shutdown measured
daemon exit 0. Both writer seals match their actual 3,609/3,604-byte transcripts
and hashes. Both positive EOF cases measured actual ACP exit 0 and delivered
1,072,333 stdout bytes with the complete 524,288-byte fixture. Their complete
outputs match each other and both first-published-candidate samples after
normalizing only exact temporary roots and session UUIDs.
The stalled reader measured actual ACP exit 1 after 2,028 ms with the original
2,000 ms drain error. The closed reader measured actual ACP exit 1 with EPIPE
after 24 ms. Both retain their original harness FAIL/script exit 1; acceptance
means the expected failure occurred, not that either output was complete. The
process-query test measured SIGTERM at 2,002.45 ms with both pipes undestroyed.
The timeout and separate exit-7 query both reject incomplete cleanup proof,
even though known owned processes exit 0 and the registry becomes empty.
The full 1,079-file artifact and the separately imported process-registry build
match before and after E2E; all 34 non-document candidate files also match.
The CLI SHA-256 measured for `f0153063d2` is
`0d7c5f8757f584b321322da34d1926666b61e6b1b23b57b6a9630c03cda43a69`;
the full dist tree digest is
`d6ec947a9fdf9c00e97f086d9c4e0cb6ab4dbeea7949768bdc4ec1ec5639e261`.
Owned PIDs/process groups and listeners were independently checked after each
scenario. Public subset comparisons match their original baselines; raw normal
prompt responses contain no new task-output/task-result/output-mode or
background-turn metadata. These ordinary fixtures do not exercise authenticated
per-task background capture or permissions. Evidence for this integration is indexed by
`latest-main-e2e-manifest.json`; earlier samples and failures remain unchanged.
## Lint-gate integration at `cc0f7c6949`
This merge added main `a98711330c43ba6f434cc9641abda10079bb8b75`, including
Session Stop-hook changes and the filename allowlist required by the lint
freshness gate. The preceding `f0153063d2` bundle hashes and eight process E2E
samples do not describe this integration.
Fresh build, workspace typecheck, bundle, full lint/static checks, core exports,
serve bundle boundary and runtime critical-dependency checks passed. Focused
verification passed 1,557 tests: 1,052 CLI, 459 core, 37 Web Shell and nine
lint-freshness helper tests. The full ACP suite and eight process scenarios
were not rerun locally for this lint-gate merge. The live GitHub comparison
reproduced the original freshness failure and passed with this merge.
At the 2026-09-16 review follow-up, this commit's GitHub checks had completed:
Linux tests, lint/static, no-AK integration, Serve A/B, daemon E2E, desktop
shells, Web Shell smoke and TUI checks succeeded. The optional macOS/Windows
Node test lanes and CLI integration lane were skipped; they are not passes.
These results do not erase the historical full-suite failures recorded above.
## Review follow-up and main integration on 2026-09-16
Published candidate `b8124075c63276d09987b9994005435c95739314` merged main
`888528dfae9b1dd0ea55ef2bab2e94bfcbaa911d` into `cc0f7c6949` and addressed the
six review suggestions. The results in this section belong to that candidate.
The frozen 36-file non-document candidate manifest has SHA-256
`90f2d468919cf77b2016868781994e0ea30b3cc1061ab8d0a4752e8c64e8fc53`.
Its digest uses sorted repository-relative paths and file SHA-256 values,
joined as `path + " " + hash` with newlines and no trailing newline.
All 17 upstream bridge patch blocks, containing 78 added and five removed
lines, were reapplied to the control plane. The entire resulting file matches
the candidate after only the shared exclusion-type declaration change. Summary
projection, synchronous validation, queued and mid-turn mode identity,
dispatch/reset ordering and child-request filtering remain session policy.
The new process-budget admission retains upstream's shared physical registry,
typed errors and rollback. The six other overlapping files match independent
three-way merges. Public package exports are unchanged relative to this main.
The teardown annotation now names the post-split owners and retains the
physical-membership/attach-availability distinction. A shared type removes the
duplicated work-exclusion declaration through a type-only import; it does not
force future implementations to read every new field. Both EOF documents name
all three mocked suites and distinguish the two suites' historical four-test
repair from that three-suite inventory.
Fresh build, workspace typecheck, bundle, all 112 consumed core subpath exports
and the serve bundle boundary passed. The complete ACP bridge suite passed
2,158 tests in 44 files; 22 affected CLI files passed 2,523 tests; two core files
passed 97 tests, for 4,778 in these runs. Earlier concurrent verification was
interrupted by cleaned build outputs and regenerated coverage; those failed
runs remain separate. The isolated lint environment also needed its installed
YAML tool on PATH. After document formatting, the complete lint pipeline passed,
including ESLint, workflow/shell/YAML checks and Prettier.
Three independently observed mutation baselines passed 143 tests. All eight
single mutations were rejected by the added named assertions: direct settings
close, direct skill close, each of the five adjacent exit-step swaps, and
unconditional cancellation of the replacement's idle timer on an old exit.
They were assertion failures, not collection errors or timeouts. In the recorded
final-step swap, the original replacement-flow test also failed with
`BridgeChannelClosedError`; it does not directly assert the exit-step order.
The dedicated six-step array assertion is the direct ordering evidence. Two
limited historical witnesses load only the exact `cc0f7c6949` test files over
current production dependencies: the old settings test survives
direct close (46 passed), and the old harness test survives swapping the first
two exit steps (two passed). These are not whole-commit historical runs.
All 13 valid mutation arms retained identical source, test, real-helper and
index hashes. The first observer configuration accidentally concatenated the
package-wide include patterns; it was stopped with exit 130, its original log
and cleanup of observed ports/processes were retained, and it is excluded from
the valid baseline/mutation counts. Exact-file filtering and load receipts
verified each subsequent observation. Evidence is indexed by
`.qwen/investigations/issue-11866-comments/mutation-summary.json`.
All eight serial process scenarios met their positive or expected-negative
acceptance conditions on the frozen artifact. Public multi-session lifecycle,
native watching with 32 skills and active-writer shutdown each measured daemon
exit 0. Both writer seals match their actual 3,604/3,609-byte transcripts and
hashes. Both positive EOF cases measured actual ACP exit 0, complete
524,288-byte fixture content, 1,074,030 stdout bytes and a final newline.
The complete outputs match after normalizing only exact temporary roots and
session UUIDs. Against the previous `f0153063d2` samples, the sole changed leaf
is the bundled workflow-authoring body: 1,668 more UTF-8 bytes, or 1,697 bytes
in the JSON frame. Its source and copied artifact match incoming main exactly;
this cross-version mismatch is retained, not normalized away.
The stalled reader measured actual ACP exit 1 after 2,023 ms with the original
2,000 ms drain error. The closed reader retained EPIPE and actual ACP exit 1.
Both original harness FAIL/script exit 1 results remain expected negatives,
with no forced cleanup signal. The process-query timeout sent SIGTERM at
2,001.07 ms while both pipes remained undestroyed. Timeout and exit-7 queries
both rejected incomplete cleanup proof even though the known owned processes
exited 0 and the registry count reached zero. Independent checks found no owned
PID, process-group or listener residue after any scenario.
All 1,082 artifact files, the separately imported process-registry build and
all 36 non-document candidate files match before and after process testing.
The CLI SHA-256 is
`dc7442c643cc03d8c939bf514d1e4075e70c90f02b9dac401497a1ea133f6074`;
the full dist tree digest is
`317c38b5bda31f45b0a924952a78d3516778f4160d1fee64816726d533562ec5`.
This is evidence for this integration, not a relabeling of earlier artifacts.
The ordinary-prompt fixtures retain the background/permission and latency
coverage limits above; full-workspace acceptance remained open at this stage.
## Idle-child reclamation integration at `3a00c42948`
Integration with main `9071c4eb705cf92ba517e007eb03b6178d7b8352` ports #11940
idle-child reclamation to the extracted boundary, including candidate recency,
busy guards and startup admission context. Before the port, all six unchanged
upstream idle-reclamation tests failed on missing provider methods. After the
port, the complete ACP bridge suite passed 2,173 tests in 45 files.
Fresh dependency installation, build, workspace typecheck, DEV bundle, all 113
consumed core subpath exports, serve bundle boundary and lockfile checks passed.
ESLint passed for the four changed production files. The first nine-file CLI
run had 2,062 passes and one failure: the invalid `entryIndex=abc` request
expected 400 but received 401. Rerunning the complete server suite in the same
isolated fixture, without changing source or assertions, passed all 1,296
tests. Together with the other eight files' 767 passes, this supplies passing
evidence for 2,063 related assertions, not an all-green original nine-file run.
The original failure remains recorded and unattributed.
The new two-workspace, capacity-one daemon scenario passed on the frozen local
bundle (`FU1SmT`), with an explicit 30,000 ms initialization timeout, 60,000 ms
channel idle timeout and disabled session reaper. Physical children changed from A epoch 1/PID 18795 to B
epoch 1/PID 18878, then A epoch 2/PID 18884. A loaded idle session protected that
last child: B received 503 `acp_child_capacity_exhausted`, A retained its PID and
epoch, and both prompts around the refusal received their own reply and exactly
one correlated terminal event. After session deletion, B started at epoch 2/PID 18951. All five checkpoints reported one committed child; retired child absence
was checked separately. Workspace registration responses and saved registration
bytes remained unchanged.
One SIGTERM produced actual daemon exit 0 and stream close 0, without forced
kill. Independent checks found no owned PID/process group or either listening
port remaining. This is one two-workspace capacity scenario, not exhaustive LRU
ordering, concurrency or latency evidence. Direct ACP exit codes were not
inferred from child absence, and the earlier eight EOF/query/public scenarios
were not rerun for this integration.
The global 0.23.3 baseline rejected `--child-heap-mode admit` and exited 1 before
listening, so no global-versus-local reclamation match is claimed. The first
local observer (`SSviMm`) also failed: it treated listener readiness as runtime
readiness, read `childHeap=null` from `runtime.loading=true` and threw. Its
cleanup SIGTERM interrupted runtime mounting; actual daemon exit 1 is retained.
The corrected observer only polls the existing public readiness field within
its original 45-second observation bound before the same capacity assertions;
the fixture's product configuration, deadlines and assertions did not change.
All 1,083 dist files, four changed production modules and the separately
imported registry build matched before and after the samples. CLI SHA-256 is
`326c87c1a454f0ff42d03066bf66f005877a5ff84c2de632475e677328b3c853`;
the recorded dist manifest digest is
`2dfcc70b196ea01e036301649484eb611a3b9130b733af12e84b866db65c2d12`.
None of the historical totals or artifacts above are relabeled as these
results. Current logs are the `conflict2-*` records in the completion
investigation directory; process evidence is under
`.qwen/investigations/issue-11866-conflict2/`. See the corresponding PR follow-up
for final lint and audit outcomes.
The review also identified a Linux two-file core run with 91 passes and six
skill-manager failures at `b8124075c6`. Its base comparison was blocked by missing
build prerequisites, so the failures remain unattributed. The earlier local
97-pass run does not resolve that Linux observation. A new macOS diagnostic
uses the same isolated fixture and six original assertions: supplying
`QWEN_HOME` produces six failures; removing only that override produces six
passes. The complete two-file run without the override then passed all 97
tests, with unchanged source/test identity. This verifies an override-induced
failure mechanism locally; it does not establish the environment or cause of
the earlier Linux run. The three observations remain separate from the
historical full-workspace failures. Evidence is under
`.qwen/investigations/issue-11866-conflict2/skills-*`.
## Workflow start-input integration
The next main integration, `04721b5dca49e2a100de4d84257a7fa945a698a3`,
preserves #11979 by moving its workflow-action method into the control plane
verbatim. It forwards `args`, `sourceRef` and `script`, retains an explicit
`args: null`, and preserves the old request shape when input is absent. Client
ownership and the session-reset barrier remain in the same order. The two
automatically merged ACP agent files match their three-way merge; existing
EOF cleanup is unchanged.
Before the port, the six unchanged upstream workflow tests had four passes and
two assertion failures for lost fields. After the port, all six passed with
the same test bytes. The built method was also inspected; this is source-test
and build evidence, not a new real-daemon E2E run. Historical process results
above remain tied to their original commits. Fresh integration checks and
final audits are recorded in the PR follow-up; raw reproduction and verification
are under `.qwen/investigations/issue-11866-conflict3/`.
## Post-merge acceptance
PR #11916 merged as `fa336618c26f0655ab25f8c1e4bdab103154bcbd` on
2026-09-16. Its Git tree, `8cf8a5feaff593f84b3f7538b6e2c937a992b64a`,
matches the final PR head `803a0380a17c57cafb02782b9ef4a22046f9db18`.
The checkout logs for both jobs below identify the merged commit, not a later
main revision. These are completed CI results inspected after merge, not new
local test runs.
The [post-merge unit job](https://github.com/QwenLM/qwen-code/actions/runs/35068367808/job/104703832563)
ran all 22 configured `test:ci` workspaces and then the root script suite:
| Suite | Passed | Failed | Skipped |
| -------------------- | -----: | -----: | ------: |
| Workspace unit tests | 77,222 | 0 | 104 |
| Root script tests | 2,493 | 0 | 12 |
This includes 2,179 ACP bridge, 31,744 CLI, 26,808 core, 2,046 TypeScript SDK
and 8,386 Web Shell passes. The
[post-merge static job](https://github.com/QwenLM/qwen-code/actions/runs/35068367808/job/104703832566)
also passed its configured Node helper suite: 580 passed, zero failed or
skipped. Both jobs completed successfully. The
[final PR unit job](https://github.com/QwenLM/qwen-code/actions/runs/35066664366/job/104698456229)
separately reports the same workspace and root-script totals on the identical
tree.
These results satisfy the configured Linux full-suite acceptance for #11866.
The run used Node 22, an isolated CI home, coverage collection and the existing
`--retry=2` policy. `QWEN_SKIP_LATENCY_BUDGETS=1` was enabled on the shared runner.
It does not establish a retry-free pass or validate the skipped latency
assertions. The macOS and Windows unit jobs were skipped; no all-platform
full-suite pass is claimed. Python, Java, mobile and real-daemon results remain
bound to their separately recorded runs.
Earlier local failures and incomplete diagnoses above remain historical
evidence; this Linux result neither fixes nor assigns a cause to them. No
production code, tests, assertions, deadlines or CI policy changed to obtain
this acceptance record. The boundary extraction is complete; stateless
`wake(sessionId)` / `getEvents()`, worker pooling and external event storage
remain follow-up work under [#11868](https://github.com/QwenLM/qwen-code/issues/11868).

View file

@ -0,0 +1,500 @@
# 完成 ACP 控制面与 harness 边界
[English](2026-09-15-acp-bridge-completion.md) | [简体中文](2026-09-15-acp-bridge-completion.zh-CN.md)
状态:已通过 [#11916](https://github.com/QwenLM/qwen-code/pull/11916) 合入;
2026-09-16,合入提交通过了配置中的 Linux CI 完整测试。
证据及限制见[合入后验收](#合入后验收)。在[前四个切片](2026-09-14-acp-bridge-control-plane-harness-boundary.zh-CN.md)
之后实现 [#11866](https://github.com/QwenLM/qwen-code/issues/11866) 的剩余边界抽取。
## 问题与范围
完成前四个切片后,bridge 仍在同一个闭包中持有会话策略和物理通道监管。已有生命周期、握手、
传输及启动模块可复用,但启动模块的通道类型仍包含会话状态,物理退出及空闲
调度仍直接修改这些状态。
最终保留两个主要所有者。`session-control-plane.ts` 集中持有会话注册表、
准入/FIFO、中途消息提升、终态去重及 artifact 转发。`channel-harness.ts`
持有物理通道及执行职责,复用已有四个模块。`bridge.ts` 仅保留同步公共组装及
兼容导出。内聚的控制面模块可以仍然较大,行数不是验收标准。
这是保持行为的 TypeScript 抽取。`deriveConfig` 保留在 core。本次不新增公共
选项、包导出、daemon 路由、协议字段、JVM 实现、事件存储或无状态 harness API。
监听器、进程查询及 EOF 退出修复分别保留独立证据,且包含有意的行为变化。
其中,EOF 排空失败现在报告退出 1,而测量的基线在输出截断或 EPIPE 时仍退出 0。
排空仅覆盖已转交内层 stdout writer 的帧;上游仍在排队的调用可能被丢弃,
只由 SDK 记录错误,进程仍退出 0。精确边界及测量见
[EOF 设计](2026-09-15-acp-eof-output.zh-CN.md)。
## 状态与构造边界
`HarnessChannel` 持有物理通道、连接、标识、传输失败/liveness 事实、握手状态、
active-work 协商及资源采样。控制面私有的 `ChannelInfo` 持有 `BridgeClient`、
会话 id、待完成操作、settlement 定时器、隔离及工作区策略,并引用物理句柄。
可保留不可变的标识/通道/连接别名;可变物理字段必须通过物理句柄访问。
私有 `WeakMap<HarnessChannel, ChannelInfo>` 在同步构造时关联一次。它不是第二份
存活注册表,不遍历,也不在清理时同步增删。只有 harness 持有 current/alive/starting
成员关系。替代通道成为 current 后,旧会话及退出回调仍保留原来的关联。不使用
getter 代理,不暴露可变注册表。
控制面提供一个同步构造动作:创建 ACP client 及受保护连接,创建两份关联对象,
绑定回调并返回物理句柄。启动模块紧接着登记句柄,再进入原来的 initialize await。
构造、登记、能力发布及现有 ensure 调用不增加异步包装。harness 只导入 ACP
client 契约,不导入 `BridgeClient` 或会话状态。
## 生命周期与执行职责
| 职责 | 所有者与边界 |
| ------------------------------------------------ | ------------------------------------------ |
| 物理 current/alive/starting 状态及 runtime epoch | Harness,保留原始槽位和新旧通道重叠语义 |
| 空闲定时器、keep-alive 和 runtime reservation | Harness,同步读取控制面当前的忙碌/回收事实 |
| 会话 reaper、关闭宽限、队列及终态事件 | 控制面,集中保留原子状态转换 |
| 隔离及被放弃请求的 settlement 策略 | 控制面,拒绝新会话但不禁用存量兄弟会话 |
| ACP client 路由及会话清理 | 控制面回调,保持原来的同步调用位置 |
| 受保护连接、进程终止及物理 shell 执行 | Harness,保留错误及直接 promise/回调顺序 |
| Worktree 元数据和转移/reset 屏障 | 控制面,显式传入选定会话 cwd 及请求归属 |
空闲判定包含外层 restore、通道内尚未完成的创建/恢复、工作区操作及 runtime
reservation。保留已判定需要回收的通道在会话工作排空后即可退出的例外。
定时器回调读取当前事实。预留仍同步取得,释放保留原来的回收/空闲 await。
不在现有 SDK 之上新增通用 RPC 分派门面。
一个工作区的多个会话可以在同一个子进程上使用不同 worktree。绝不把通道 cwd
重新绑定到 worktree。将现有 worktree defer 元数据、会话 cwd 请求及物理 shell
执行移入 harness 操作,保留请求展开优先级、trace 注入、实际所有者通道及原有
cwd 队列。控制面保留授权、reset 屏障、结果校验、元数据更新和用户事件/历史发布。
Shell 取消和期限也留在会话控制面;harness 同步返回物理执行 promise,保留原来
的两处 await。
## 退出、关停及组装顺序
物理退出时,依次停止 liveness、清理传输刷新、取消当前通道空闲定时器、执行
控制面定时器/预留释放清理、移除物理句柄,再执行控制面会话清理。严格保留此顺序
及原有会话终态先于事件总线关闭的顺序。旧通道迟到退出不能清除替代通道。不新增
异步事件分派器。
控制面保留关停准入及共享 shutdown promise 的身份。harness 的快照/标记、
terminate 和 killSync 操作在原位置执行。物理清理、启动、会话创建、恢复及
被放弃操作的 settlement 保持在同一次联合等待中。不得在会话终态发布前开始
终止进程,不得在实际退出前删除句柄。
Bridge 构造在原初始化位置向控制面提供同步 harness 工厂。保留选项校验顺序、
journal 登记、会话 reaper 启动及 prompt handler 绑定。公共错误 helper 和
`createHttpAcpBridge` 别名仍通过原模块导出。内部接口是具体的 TypeScript
边界,不冻结未来的 Java ABI。
## 实施与验证
1. 分离物理与控制面通道类型,建立一次性关联。
2. 将完整的空闲/预留/退出/关停职责及执行 helper 移入 harness,保留现有
函数体及 await 位置。
3. 移动剩余的内聚控制面,使 bridge 成为组装入口。
4. 机械核对源码移动及导出/协议表达式,随后执行 build、typecheck、bundle、
受影响测试、公共 E2E、完整的仓库已配置单元测试及脚本测试,再进行两轮
无问题自审及独立审查。
保留现有工作区隔离、启动合并、启动失败、新旧通道重叠、同会话 FIFO、跨会话
并发、退出扇出、空闲/preheat、恢复、终态、artifact 及 worktree/reset 测试。
对新边界的覆盖缺口增加确定性测试,包括同步关联及退出释放顺序。E2E 计划为
`.qwen/e2e-tests/issue-11866-completion.md`,复用已有全局公共基线,并检查
真实进程退出及独立资源清理。
验收要求职责显式分离、harness 不持有会话注册表/队列/事件、bridge 不持有物理
生命周期状态、公共/协议行为保留、验证通过且审查问题解决。历史失败继续保留在
原报告中,直到证据确定结果。
## Rebase 前的边界与证据
本节记录基于 `9efdd898e7` 的 rebase 前本地提交
`88a5eb2bce7fc1dd3e932176dfd8d08ff82afe97`。2026-09-16 核对时,该对象仍存在于
作者本地 Git 对象库,但不是已发布 `b8124075c6` 的祖先,GitHub commit API
对它返回 422。计数和测试结果作为作者的历史证据保留,不是可公开解析的候选,
也不代表当前验证。下文的 14,267 行、2,092 项测试及 44 文件计数仅属于该
rebase 前候选。
合并更新的 main 前,公共 bridge 仅有 23 行同步组装及兼容导出。14,267 行控制面保留内聚的会话
策略闭包;444 行 harness 持有物理生命周期与执行,复用已抽取的生命周期、
启动、握手、传输及连接 helper。公共包导出未变,严格 TypeScript 导出类型与
基线相同,`createHttpAcpBridge` 与公共工厂仍是同一个函数对象。
机械对照验证最终控制面相对上一阶段的完整移动、13 个生命周期函数体、连接
声明、原通道 27 个初始化字段在两类新对象中的对应一致,以及按声明归属确定的
物理属性访问。另一项比较匹配了
306 个按名称索引的存量控制面函数体;因为存在重复局部函数名,不把它称为全部
函数的证明。八处改动的接线函数体另行直接审查,包含同步构造、退出、cwd、
shell 和关停。历史迁移函数体清单包含 270 个 await;这不是最终所有模块中
await token 的总数。时序依据来自逐函数及接线检查,不能仅凭计数证明。
新增回归测试命中了“把物理句柄移除放在会话生命周期回调之后”的变异;该历史
变异没有覆盖全部六步退出顺序。
Build、工作区 typecheck、lint、格式、core 子路径导出及 serve bundle 边界检查
通过。完整 ACP bridge 测试共 2,092 项通过。两轮无问题自审及对冻结 44 文件候选
的独立审查未发现未决源码缺陷。证据保留在
`.qwen/investigations/issue-11866-completion/`。
八个串行最终 bundle E2E 样本满足各自验收条件:公共生命周期、原生监听下
32 个技能、两个活动 writer 会话、两个直接 EOF 场景、停读和 EPIPE 负例,
以及真实进程查询超时/非零退出负例。前两项公共流程与已安装 CLI 的公共基线
一致;活动 writer 关闭与此前本地基线一致。两个正向直接 EOF 场景测得完整
stdout 及实际 ACP 退出 0;两个输出负例测得非零退出及 timeout 或 EPIPE。
daemon 子进程清理独立检查,不推断子进程退出码。E2E 前后保持同一份
1,079 文件 bundle 清单。
数秒的 DELETE 停顿继续作为测量观察保留,不宣称延迟等价。监听器、查询输出
和 EOF 独立设计继续保留历史失败及各项修复的边界。
## Rebase 前的仓库级结果与限制
完整 workspace 命令执行了全部 22 个已配置工作区:75,691 项通过、25 项失败、
117 项跳过。失败来自 CLI 19 项、core 5 项、Web Shell 1 项。已使用与 lockfile
完整性匹配的安装包及未改动的仓库补丁恢复缺失的 Ink 补丁,随后三项循环保护
测试通过。部分原测试还读取了真实用户的设置、用量和记忆。对同样 25 项断言,
使用隔离子进程环境和单 worker 得到 16 项通过、9 项失败。这不能替代失败的
完整运行,也不能为每项失败指定同一个原因。
在原模块 ID 加载精确 HEAD scheduler 后,三项 cron 断言也失败。将已观察到的
改动 source/dist 依赖替换成 HEAD 等价实现后,logger 期限断言也失败。
metrics 竞速在后续两组配对运行中均通过。这些是有界模块对照,不是整个工作树
的 HEAD 运行。独立诊断记录了未改动 AuthDialog 的状态竞争、依赖 Git 版本的
断言及一次通过的 AddMenu 焦点观察;原始失败和未捕获的原生监听/shim 原因继续
保留。没有通过修改源码断言或期限来取得绿色结果。
| 其他验证 | 结果 |
| ---------------------- | ------------------------------------------ |
| 完整脚本套件 | 87 个文件;2,412 项通过、75 项跳过;退出 0 |
| 27 个 CI helper 文件 | 528 项通过、42 项失败、4 项跳过;退出 1 |
| Python SDK | 隔离 Python 3.12 环境下 173 项通过 |
| Mobile 单元测试 | 使用匹配的 Playwright runner,43 项通过 |
| Channel plugin example | 4 项通过 |
42 项 CI helper 失败均位于未改动的 Linux triage 包装脚本测试中(gate 39 项,
staging 3 项)。对一项未改动的 all-pass 断言作直接观察:macOS 上的 GNU
stat/proc inode 守卫在任何采样轮次前即拒绝,runner 调用为零,gate log 尚未
创建。staging 日志也记录了缺失的 `/usr/bin/rm` 路径。当前 PATH 未找到 Docker
命令,本轮未验证 Linux CI。这些失败结果继续保留,不称为 CI 已通过。
恢复的 Ink 依赖只有 box-metrics hook 发生变化。重新执行完整 build、typecheck、
bundle、serve bundle 边界及 core 导出检查,全部通过。新的 1,079 文件 bundle
已冻结在 `post-install-bundle-manifest.json`。八个公共 E2E 场景在重新构建的产物上
也满足原验收条件,完整产物清单在验证前后保持一致。两个正向 EOF 场景均输出
1,062,289 字节 stdout,实际 ACP 退出 0;负例保留实际退出 1 及原始 timeout
和 EPIPE。所拥有的进程和监听端口均已消失。规范化的正向输出与此前最终 bundle
一致,历史失败另行保留。在该阶段,由于仓库级命令尚未全绿,完整测试验收仍未关闭。
详细证据及独立后续事项保留在
`.qwen/investigations/issue-11866-completion/` 和
`.qwen/issues/issue-11866-unrelated-unit-followups.md`。
## 首次发布候选:main 整合
首次发布的候选 `768e1b194f` 合并了 main `d47a8fdbae49d6acd4b7b4fb2eb650200e2ae9c8`,比原始
基线新增 16 个提交。相对 rebase 前候选,有五个非文档候选文件发生变化。bridge 仍为
23 行,控制面现在为 14,465 行,harness 仍为 444 行。更新后 main 的后台回合
准入、终态等待、取消 epoch、active-work 快照、恢复元数据和忙碌守卫继续留在
控制面。上游工作区 hooks 和扩展 workflow 监听均保留。
三个冲突区域在抽取边界处解决:导入、物理 active-work 存储和同步 client
构造。独立无方向及假定结论错误的反向源码审计重新核查完整合并增量及其下游
消费者。按已声明的物理访问对应关系,全部 25 个 client 构造实参与更新后的
main 一致。一项独立比较匹配 305 个按名称索引的存量函数体;另一项保留重复
名称的出现次数,检查全部 20 个上游改动的具名函数体。两者都不是全程序证明。
非机械的构造、退出、关停、cwd 和 shell 接线均直接阅读。新的 await 统计为
271,不是旧的 270;时序证据来自实际语句和回调。
重新执行 build、工作区 typecheck、lint、bundle、core 子路径导出及 serve
bundle 边界检查,全部通过。Rebase 后单元测试结果独立记录:
| 验证 | 结果 |
| ---------------------- | ------------------------------------------- |
| 完整 ACP bridge 套件 | 42 个文件;2,130 项通过 |
| 11 个受影响 CLI 文件 | 隔离包装环境中 1,905 项通过、2 项失败 |
| 两项 CLI 目录断言诊断 | 移除包装脚本的运行目录覆盖后,两项均通过 |
| 三个受影响 core 文件 | 隔离包装环境中 228 项通过、9 项失败 |
| 六项 core 技能断言诊断 | 移除包装脚本的 Qwen home 覆盖后,六项均通过 |
| Serve bundle 守卫测试 | 40 项通过 |
两项 CLI 失败收到的是包装脚本的 `QWEN_RUNTIME_DIR`,而不是各测试设置的
目录。未改动的 main 实现明确优先使用该环境变量。复用同一隔离 home/settings,
仅移除冲突覆盖后,两项原断言均通过。这是有界诊断,不能替代一次全绿的
1,907 项测试运行。
六项 core 失败同样使用了包装脚本的 `QWEN_HOME`,而不是测试提供用户技能
fixture 的模拟 home 目录。只移除该覆盖,并保留同一隔离 home、runtime 和
系统设置后,六项原断言全部通过。原始 228 项通过/9 项失败的运行继续与这项
诊断分开记录。
三项 core 失败继续记录在持久化 cron 监听中:600 毫秒后未观察到监听器读取、
afterEach 清理 hook 超过 10 秒,以及 3 秒内未观察到外部任务。本次 hook
超时不改称历史断言失败。先前基线对照仍是历史证据,不能为本次每项失败确定
原因。没有通过修改产品代码、断言或期限来让结果变绿。因此在该阶段,完整测试
验收仍未关闭。
八项串行 E2E 在重新构建的候选上均满足各自正向或负向验收条件。公共生命周期、
32 技能原生监听和活动 writer 关停均测得 daemon 退出 0,并独立检查清理。
两个 writer 封存记录均与实际 3,609/3,604 字节 transcript 及哈希一致。两个
正向 EOF 场景均输出 1,072,333 字节 stdout,包含完整的 524,288 字节 fixture,
实际 ACP 退出 0。停读和断管场景保留 harness FAIL/退出 1,实际 ACP 退出 1,
并保留原始 2,000 毫秒排空错误或 EPIPE。查询负例回退在 2,002 毫秒发出
SIGTERM 时,两条管道均未销毁;超时和另一次退出 7 都报告快照失败,即使所
拥有的进程已正常退出。
仅规范化精确临时目录和会话 UUID 后,两个新正向 EOF 输出相同。历史比较仍为
MISMATCH:CLI 版本和两个内置技能正文不同。正文与更新后上游内容逐字节一致,
解释了额外 10,044 字节。本次生命周期样本还捕获到一条既有命令目录通知,
已安装 CLI 样本也有该通知,而上一轮本地样本没有。公共子集相同不代表完整
协议相同,这些普通提示 fixture 也不穷尽工具驱动的后台回合。
完整 1,079 文件产物清单在 E2E 前后相同,单独导入的 process-registry 构建也
相同。CLI SHA-256 为
`5e3d6af2d543ffed89610eec76248be54badd740a9b5d106d56ad68a116adef5`;
完整 dist 树摘要为
`8e9ce5cfc6a2575c6679566a3675726e0d58f57accaf80ac10aafb01ce13fd2d`。
所拥有的进程和监听端口均经过独立检查。直接测量的 ACP 退出继续与 daemon
子进程消失的观察分开。Rebase 证据由 completion 调查目录内的
`rebase-e2e-manifest.json` 索引;源码审计及重新构建的产物清单位于
`.qwen/pr-reviews/`。上文继续保留历史失败及其边界。
## 后续合并渠道输出模式
本节证据采集于 `f0153063d2`。
首次发布后,main 前进到 `473ef4b3e474ddc16d7bd6db32fcc86185a9cac6`。
其 bridge 改动与抽取产生冲突。新增六行保留在控制面原有的同步请求构造位置:
先捕获传入的输出模式,无条件清除,再仅为可信 channel-prompt 上下文恢复
`per_task`。ACP agent 保留独立的可信父进程检查。模式策略和会话 capture
状态均未移入物理 harness。
合并也保留了上游按会话持有的权限队列,以及完整的 channel-task capture、
排队、取消、dispose 和结果处理。相关任务通知继续属于正在等待的 RPC,避免
通过独立后台准入反过来等待同一个 RPC。bridge 仍为 23 行,控制面变为
14,471 行,harness 仍为 444 行。
两轮独立无方向和反向源码审计没有发现引入缺陷。相对 `768e1b194f`,仅三个
非文档候选文件不同,其新增内容分别与上游的六行 bridge、六行 ACP 和 42 行
测试一致。所有其他非候选文件与新 main 精确一致。检查了完整请求构造和 ACP
prompt 处理,而不只核对行数。重新执行 build、工作区 typecheck、lint、
bundle、core 导出和 serve bundle 边界检查,全部通过,随后 2,130 项 ACP
bridge 测试全部通过。
15 个受影响 CLI 文件中的 2,170 项测试也全部通过,包括完整 Session 套件、
可信/伪造渠道模式过滤和渠道配置。这些命令保留隔离 HOME 及系统设置,同时
显式取消包装脚本的 `QWEN_RUNTIME_DIR` 和 `QWEN_HOME`,允许测试自行提供
runtime 和模拟 home fixture。原断言及期限不变,先前包装环境的失败运行
继续保留在上文。
八个相关 channel-base 文件中的 1,150 项测试全部通过,覆盖两种渠道 bridge、
会话路由、输出模式、输出回合和后台输出协调。结合上述 CLI 测试,这些检查
补充了八项普通提示进程场景没有覆盖的可信 `per_task` 及权限行为。
新的八项串行 E2E 均满足各自验收条件。公共生命周期、32 个技能的原生监听和
活动 writer 关停均测得 daemon 退出 0。两个 writer 封存记录与实际的
3,609/3,604 字节 transcript 及哈希一致。两个正向 EOF 场景均测得 ACP
实际退出 0,并交付 1,072,333 字节 stdout,包含完整的 524,288 字节
fixture。只规范化精确临时根目录和 session UUID 后,两份完整输出彼此一致,
也分别与首次发布候选的两份样本一致。
停滞读端在 2,028 ms 后测得 ACP 实际退出 1,保留原有 2,000 ms 排空错误。
关闭读端在 24 ms 后测得 ACP 实际退出 1 并保留 EPIPE。两者都保留原始
harness FAIL/脚本退出 1;验收通过表示预期失败发生,不代表输出完整。
进程查询测试在 2,002.45 ms 时测得 SIGTERM,两个输出管道均未被 destroy。
查询超时和单独退出 7 均拒绝不完整的清理证明,即使已知所属进程退出 0、
registry 已清空,也不报告完整终止成功。
完整的 1,079 个构建文件和单独导入的 process-registry 构建在 E2E 前后
一致,全部 34 个非文档候选文件也保持一致。在 `f0153063d2` 上测得的 CLI SHA-256 为
`0d7c5f8757f584b321322da34d1926666b61e6b1b23b57b6a9630c03cda43a69`;
完整 dist 树摘要为
`d6ec947a9fdf9c00e97f086d9c4e0cb6ab4dbeea7949768bdc4ec1ec5639e261`。
每项场景后均独立检查所属 PID/进程组和监听端口。公共子集比较与原基线一致;
普通提示的原始响应没有新增 task-output/task-result/output-mode 或
background-turn 元数据。这些普通 fixture 不覆盖经授权的 per-task 后台
捕获或权限流程。本次集成的证据由 `latest-main-e2e-manifest.json` 索引;
更早的样本及失败保持原样。
## `cc0f7c6949` 的 lint 门禁集成
本次合并纳入 main `a98711330c43ba6f434cc9641abda10079bb8b75`,包括
Session Stop-hook 改动和 lint freshness 门禁要求的文件名白名单更新。上一节
`f0153063d2` 的 bundle 哈希及八个进程 E2E 样本不描述本次集成。
重新执行的 build、工作区 typecheck、bundle、完整 lint/static、core 导出、
serve bundle 边界及运行时依赖 Critical 检查通过。定向验证通过 1,557 项测试:
CLI 1,052、core 459、Web Shell 37、lint freshness helper 9。本次 lint 门禁
合并没有在本地重跑完整 ACP 套件和八个进程场景。真实 GitHub 比较复现了原来的
freshness 失败,并在合并后通过。
2026-09-16 处理评审时,该提交的 GitHub 检查均已完成:Linux 测试、lint/static、
no-AK 集成、Serve A/B、daemon E2E、desktop shell、Web Shell smoke 及 TUI
检查成功。可选的 macOS/Windows Node 测试通道和 CLI 集成通道被跳过,不能计为
通过。这些结果不抹去上文保留的历史完整测试失败。
## 2026-09-16 评审跟进与 main 集成
已发布候选 `b8124075c63276d09987b9994005435c95739314` 在 `cc0f7c6949` 上
合并 main `888528dfae9b1dd0ea55ef2bab2e94bfcbaa911d`,并处理六条评审建议。
本节结果属于该候选。冻结的 36 个非文档候选文件清单 SHA-256 为
`90f2d468919cf77b2016868781994e0ea30b3cc1061ab8d0a4752e8c64e8fc53`。
摘要按仓库相对路径排序,将每项 `path + " " + hash` 以换行连接且无尾部换行,
其中 hash 是对应文件的 SHA-256。
上游 bridge 的 17 个补丁块(新增 78 行、删除五行)全部重新应用到控制面,
结果仅经过共用排除类型的声明调整后便与候选全文一致。摘要投影、同步校验、
排队及中途消息模式身份、派发/重置时序、子进程请求字段过滤仍属于会话策略。
新增进程预算准入保留上游共享物理 registry、类型化错误及回滚。另六个交集文件
与独立三方合并结果一致。相对本次 main,公共包导出保持不变。
退出注释现已指向拆分后的实际所有者,并保留物理成员关系与可附着状态的区别。
共用类型通过 type-only import 消除工作排除声明的重复;它不保证未来实现一定
读取每个新增字段。两份 EOF 文档列出全部三组 mock 测试,并将后两组历史四项
测试修复与三组清单明确区分。
重新执行的 build、工作区 typecheck、bundle、全部 112 个被消费的 core 子路径
导出及 serve bundle 边界检查通过。完整 ACP bridge 套件 44 个文件、2,158 项
通过;受影响 CLI 22 个文件、2,523 项通过;core 两个文件、97 项通过,本轮
合计 4,778 项。早先并发验证被构建产物清理及 coverage 重新生成打断,失败记录
另行保留。隔离 lint 环境还需要把已安装的 YAML 工具加入 PATH。文档格式处理
完毕后,完整 lint 流程通过,包括 ESLint、workflow/shell/YAML 检查和 Prettier。
独立观察的三组 mutation 基线共 143 项通过。八个单点变异均被新增的具名断言
拒绝:settings 直接关闭、skill 直接关闭、五组相邻退出步骤交换,以及旧通道
退出时无条件取消替代通道的空闲定时器。失败均来自断言,而非收集错误或超时。
已记录的最后两步交换还使原有替代通道流程测试因 `BridgeChannelClosedError`
失败;该测试不直接断言退出步骤顺序。专门的六步数组断言才是直接的顺序证据。
两项有限的历史见证仅在当前生产依赖上加载 `cc0f7c6949` 的精确测试文件:旧 settings 测试在
直接关闭变异下仍有 46 项通过,旧 harness 测试在交换前两步后仍有两项通过。
这些不代表完整历史提交的测试运行。
全部 13 个有效变异观察的源码、测试、真实 helper 及 index 哈希前后不变。
观察器最初的配置意外拼接了包级 include 模式,已以退出 130 中止,原始日志及
已观察端口/进程的清理证据保留,不计入有效基线或变异数量。后续每次观察均通过
精确文件过滤及加载记录核验。证据索引为
`.qwen/investigations/issue-11866-comments/mutation-summary.json`。
冻结产物上的八个串行进程场景全部满足正向或预期负向验收条件。公共多会话
生命周期、32 个技能的原生监听、活动 writer 关停均测得 daemon 退出 0。
两个 writer 的 seal 与实际 3,604/3,609 字节 transcript 及哈希一致。两个
正向 EOF 场景实际 ACP 均退出 0,包含完整 524,288 字节 fixture、
1,074,030 字节 stdout 及末尾换行。仅归一化精确临时根目录和 session UUID
后,两份完整输出一致。相对之前的 `f0153063d2` 样本,唯一变化的叶子是内置
workflow-authoring 正文:UTF-8 增加 1,668 字节,JSON 帧中增加 1,697 字节。
其源码和复制后的构建产物与合入 main 精确一致;这个跨版本不一致被保留,
没有通过归一化抹去。
永久停读测得实际 ACP 在 2,023 毫秒后退出 1,并保留原始 2,000 毫秒排空错误。
关闭读端保留 EPIPE 和实际 ACP 退出 1。两者的原始 harness FAIL/脚本退出 1
均作为预期负例保留,没有强制清理信号。进程查询超时在 2,001.07 毫秒发送
SIGTERM,当时两条管道均未被 destroy。超时及退出 7 的查询都拒绝不完整清理
证明,即使已知所属进程退出 0 且 registry 计数归零。每项场景的独立检查均未
发现所属 PID、进程组或监听端口残留。
全部 1,082 个构建文件、单独导入的 process-registry 构建及全部 36 个非文档
候选文件在进程测试前后保持一致。CLI SHA-256 为
`dc7442c643cc03d8c939bf514d1e4075e70c90f02b9dac401497a1ea133f6074`;
完整 dist 树摘要为
`317c38b5bda31f45b0a924952a78d3516778f4160d1fee64816726d533562ec5`。
这是本次集成的证据,不是将更早的产物重新标记。普通提示 fixture 仍保留上文
对后台/权限及延迟覆盖的限制;在该阶段,完整 workspace 验收仍未关闭。
## `3a00c42948` 的空闲子进程回收集成
与 main `9071c4eb705cf92ba517e007eb03b6178d7b8352` 集成时,将 #11940
空闲子进程回收移植到抽取后的边界,包括候选使用时间、忙碌条件和启动准入
上下文。移植前,上游六项未改动的空闲回收测试全部因 provider 方法缺失而失败。
移植后,完整 ACP bridge 套件 45 个文件、2,173 项通过。
重新执行的依赖安装、build、工作区 typecheck、DEV bundle、全部 113 个被消费
core 子路径导出、serve bundle 边界及 lockfile 检查通过。四个改动生产文件的
ESLint 通过。首轮九文件 CLI 运行有 2,062 项通过、一项失败:非法
`entryIndex=abc` 请求预期 400,实际 401。复用相同隔离 fixture,未修改源码
或断言,完整 server 套件重跑 1,296 项通过。结合另外八个文件的 767 项通过,
这是 2,063 个相关断言的通过证据,不是原九文件命令全绿。首次失败仍保留且
未归因。
新的两个工作区、单子进程容量 daemon 场景在冻结本地 bundle 上通过
(`FU1SmT`),显式配置初始化期限 30,000 毫秒、通道空闲期限 60,000 毫秒,
并关闭会话 reaper。物理子进程从 A epoch 1/PID 18795 变为 B epoch 1/PID 18878,
再变为 A epoch 2/PID 18884。A 保留已加载空闲会话时,该子进程受到保护:B
收到 503 `acp_child_capacity_exhausted`,A 保持相同 PID 和 epoch,拒绝前后
两次提示各自收到回复及恰好一个关联终态事件。删除会话后,B 以 epoch 2/PID
18951 启动。五个检查点均报告一个已提交子进程,并另行检查被回收子进程已消失。
工作区登记响应和保存的登记文件字节保持一致。
一次 SIGTERM 后实际 daemon exit 和流 close 均为 0,没有强制 kill。独立检查
未发现所属 PID、进程组或两个监听端口残留。这是一个双工作区容量场景,不是
穷尽 LRU 排序、并发或延迟的证据。不由子进程消失推断直接 ACP 退出码;本次
集成未重跑此前八项 EOF、查询和公共场景。
全局 0.23.3 基线拒绝 `--child-heap-mode admit`,在监听前退出 1,因此不宣称
全局与本地回收行为 MATCH。首次本地观察脚本(`SSviMm`)也失败了:将监听
就绪误当作 runtime 就绪,从 `runtime.loading=true` 响应读取到
`childHeap=null` 后抛错。清理 SIGTERM 中断 runtime 挂载;实际 daemon
退出 1 的结果保留。修正后的观察脚本仅在原 45 秒观察上限内轮询已有公共
就绪字段,然后执行相同容量断言;fixture 的产品配置、期限和断言均未修改。
样本前后全部 1,083 个 dist 文件、四个改动生产模块及单独导入的 registry
构建一致。CLI SHA-256 为
`326c87c1a454f0ff42d03066bf66f005877a5ff84c2de632475e677328b3c853`;
记录的 dist 清单摘要为
`2dfcc70b196ea01e036301649484eb611a3b9130b733af12e84b866db65c2d12`。
上文历史计数或产物均不重新标记为本次结果。当前日志是 completion 调查目录
中的 `conflict2-*` 记录,进程证据位于
`.qwen/investigations/issue-11866-conflict2/`。最终 lint 和审计结论见相应 PR
跟进记录。
评审另记录了 `b8124075c6` 上 Linux 两个 core 文件的运行:91 项通过,六项
skill-manager 失败。其基线对照被缺失构建产物的前置检查拦住,因此这些失败
仍未归因。此前本地 97 项通过不能解释该 Linux 观察。新的 macOS 诊断复用
同一隔离 fixture 和原六项断言:设置 `QWEN_HOME` 时六项失败,仅移除该覆盖值
后六项通过;随后无此覆盖值的完整两文件运行 97 项通过,源码/测试身份未变。
这在本地验证了覆盖值造成失败的机制,不能证明先前 Linux 运行的实际环境或
原因。三组观察与历史完整 workspace 失败分别保留。证据位于
`.qwen/investigations/issue-11866-conflict2/skills-*`。
## Workflow 启动输入集成
下一次合入 main `04721b5dca49e2a100de4d84257a7fa945a698a3` 时,将 #11979
的 workflow action 方法逐字移入控制面。它转发 `args`、`sourceRef` 和
`script`,保留显式 `args: null`,没有输入时保持原请求结构。客户端所有权
检查和会话重置屏障保持原顺序。两个自动合并的 ACP agent 文件与三方合并结果
一致,已有 EOF 清理逻辑不变。
移植前,上游六项未改动的 workflow 测试有四项通过、两项因字段丢失而断言失败;
移植后,同一份测试六项全部通过。另检查了构建后的方法;这是源码测试和构建
证据,不是新的真实 daemon E2E 运行。上文历史进程结果仍绑定原提交。新的集成
检查和最终审计记录在 PR 跟进中;原始复现与验证证据位于
`.qwen/investigations/issue-11866-conflict3/`。
## 合入后验收
PR #11916 于 2026-09-16 合入,对应提交
`fa336618c26f0655ab25f8c1e4bdab103154bcbd`。其 Git 树
`8cf8a5feaff593f84b3f7538b6e2c937a992b64a` 与 PR 最终 head
`803a0380a17c57cafb02782b9ef4a22046f9db18` 一致。下列两个 job 的 checkout
日志均指向该合入提交,而非之后的 main 版本。这些是在合入后核实的已完成 CI
结果,不是本次新执行的本地测试。
[合入后单测 job](https://github.com/QwenLM/qwen-code/actions/runs/35068367808/job/104703832563)
运行了全部 22 个配置了 `test:ci` 的 workspace,随后运行根目录脚本测试:
| 测试范围 | 通过 | 失败 | 跳过 |
| ------------------ | -----: | ---: | ---: |
| Workspace 单元测试 | 77,222 | 0 | 104 |
| 根目录脚本测试 | 2,493 | 0 | 12 |
其中包含 ACP bridge 2,179 项、CLI 31,744 项、core 26,808 项、TypeScript SDK
2,046 项及 Web Shell 8,386 项通过。
[合入后静态检查 job](https://github.com/QwenLM/qwen-code/actions/runs/35068367808/job/104703832566)
还通过了配置中的 Node helper 测试:580 项通过,零失败、零跳过。两个 job
均成功完成。[PR 最终单测 job](https://github.com/QwenLM/qwen-code/actions/runs/35066664366/job/104698456229)
在相同代码树上另行报告了相同的 workspace 和根目录脚本测试计数。
这些结果满足 #11866 在配置中的 Linux 完整测试验收。本次运行使用 Node 22、
隔离的 CI home、覆盖率采集及已有的 `--retry=2` 策略。共享 runner 启用了
`QWEN_SKIP_LATENCY_BUDGETS=1`。结果不证明无需重试即可通过,也未验证跳过的
延迟断言。macOS 和 Windows 单测 job 被跳过,因此不宣称全平台完整测试通过。
Python、Java、mobile 及真实 daemon 的结果仍绑定各自另行记录的运行。
上文此前的本地失败与未完成归因继续作为历史证据保留;本次 Linux 结果既不修复
这些失败,也不为它们确定原因。没有为取得本验收记录而修改生产代码、测试、断言、
期限或 CI 策略。边界抽取已完成;无状态 `wake(sessionId)` / `getEvents()`、
worker 池和外部事件存储仍由 [#11868](https://github.com/QwenLM/qwen-code/issues/11868)
跟进。

View file

@ -0,0 +1,118 @@
# Drain ACP output before EOF exit
[English](2026-09-15-acp-eof-output.md) | [简体中文](2026-09-15-acp-eof-output.zh-CN.md)
## Problem and evidence
The installed 0.23.3 ACP process can exit 0 after input EOF with an incomplete
`available_commands_update` frame. A controlled client paused reading for 101 ms
after the frame began, then read continuously until stdout ended. The actual
ACP process exited naturally with a 196,608-byte unterminated fragment; no
cleanup signal was sent. The launcher was bypassed using its installed Node
and CLI so the captured exit belonged to ACP itself. Earlier observations from
a harness that did not wait for complete stdout remain historical evidence only.
The SDK resolves `connection.closed` when input ends without draining its private
write queue. CLI cleanup subsequently calls `process.exit`. A successful Web
writer write is also insufficient: Node's `Writable.toWeb` can resolve below the
backpressure threshold before the native write callback. Output needs a native
finish barrier before successful process exit.
## Decision and boundaries
Give `runAcpAgent` a private output owner in `acp-output.ts`. It holds the writer
for `Writable.toWeb(process.stdout)` and gives NDJSON a gated byte stream. Normal
writes return the underlying write promise directly. After EOF cleanup, close
admission synchronously and close the underlying writer. This queues native
end/finish after frames the gated stream's sink has already forwarded to the
inner writer's `write()`; subsequent sink callbacks reject without entering
stdout. Keep the close promise idempotent.
Await the writer's `closed` promise to retain original write/finish errors, even
when `close()` on an errored stream reports only an invalid-state error. Limit
the output drain to two seconds, matching the ordinary per-cleanup budget. On
timeout, reject and destroy the native output directly; do not wait for a Web
abort that can itself be queued behind the blocked write. Timers and writer
locks are released on settlement.
Report EOF cleanup and output failures, aggregating them when both occur.
Reporting a drain failure is an observable behavior change: the Linux review's
base `a98711330c` exited 0 with truncated output for a permanently stalled reader,
and exited 0 while logging EPIPE for a closed reader. Head `cc0f7c6949` exited 1
with the drain timeout or original EPIPE respectively. This is improved failure
reporting, not preservation of the baseline exit status. IDE and embedding
clients that disconnect by closing their read pipe can observe the new exit 1.
The [Linux A/B report](https://github.com/QwenLM/qwen-code/pull/11916#issuecomment-5677832569)
records those measurements; they were not rerun for this documentation update.
Keep signal handlers installed during the bounded drain. The existing
SIGTERM/SIGINT destroy-and-exit path retains its authority and is not
reported as an orderly output drain. No change to session disposal, available
command contents, SDK queues, signal deadlines or protocol fields.
The guarantee covers frames already forwarded to the inner writer before
sealing: they finish, or the drain reports a failure. Calls still queued in the
SDK, NDJSON or gated Web stream have not crossed that boundary, even if the
caller issued them before EOF. They can later be rejected with
`ACP output is closed`, logged by the SDK, and omitted while ACP still exits 0. The
[sandbox report](https://github.com/QwenLM/qwen-code/pull/11916#issuecomment-5691411334)
measured this residual at `b8124075c6`: its 200-frame backlog delivered 93 complete
frames (200,405 of 431,090 issued bytes, 46.5%) and exited 0, versus no bytes and
exit 0 on its base. This is one harness observation, not a delivery percentage
guarantee. Upstream queue draining remains deferred.
The fix does not promise replies for every inbound RPC still running after EOF,
stop the SDK's internal queue, or deliver data to a peer that has stopped reading
permanently. It is separate from the #11866 behavior-preserving refactor.
## Verification
Use real Node Writable instances to test small writes below backpressure, large
pending writes, finish failures, original write errors, late frames, duplicate
close and a permanently stalled reader. Test EOF integration ordering, cleanup
failure preservation and overlap with signals. The independent test engineer
repeats the public client reproduction against the final bundle, waits for actual
ACP exit plus stdout end/close, and checks complete NDJSON and process/port cleanup.
Run affected tests, build, typecheck, bundle and lint, followed by two clean audits
and independent review. The original reproduction and any failed observations
stay in `.qwen/issues/issue-11866-acp-eof-output.md`.
## Final verification results
The implementation passed seven real-Writable tests and the affected ACP/CLI
tests, build, typecheck, lint and bundle checks. Three existing transport-mocked
suites mock the private output owner: `acpAgent.test.ts`,
`acpAgent.worktree.test.ts` and `plan-mode-config.test.ts`. The latter two needed
that mock after the first suite already had it; their initial failing run and
subsequent four passing tests are retained.
Two clean self-audits and independent review found no outstanding source defect.
The first final combined bundle passed both direct ACP EOF cases: EOF after the first
frame fragment, and EOF after the complete frame. Both actual ACP processes
exited naturally with code 0, a complete 1,058,795-byte command frame and all
524,288 fixture bytes. The two frames match after normalizing only session UUIDs
and exact temporary roots. The global baseline's catalog differs, so this is
not a claim of full catalog equality with the installed CLI.
A permanently paused reader produced actual ACP exit 1 and the original drain
timeout after 2,021 ms. A closed read pipe produced actual ACP exit 1 with the
original EPIPE after 16 ms. Raw harness failures remain recorded as expected
negative outcomes. Positive cases waited for stdout end/close; the EPIPE case
observed the expected closed reader. Independent checks found no owned process,
process group or listening port left behind. The 1,079-file bundle manifest was
unchanged before and after the eight combined E2E samples.
After restoring the installed Ink patch and rebuilding, all eight scenarios
met the same acceptance conditions again. Both normal EOF processes exited 0
with 1,062,289 stdout bytes, byte-identical to the earlier final output after
the same normalization. The stalled reader exited 1 with the original timeout
after 2,024 ms; the closed pipe exited 1 with EPIPE after 13 ms. The new
1,079-file manifest matched before and after, with no owned process or listener
remaining. `post-install-e2e-manifest.json` records this second artifact separately.
The reproducible EOF truncation is verified fixed within the stated output-owner
boundary. Historical observers that crashed or did not capture actual exit are
not retrospectively assigned a cause or exit code. Repository-wide validation
of the combined change is tracked in the
[completion design](2026-09-15-acp-bridge-completion.md).

View file

@ -0,0 +1,96 @@
# ACP 在 EOF 退出前排空输出
[English](2026-09-15-acp-eof-output.md) | [简体中文](2026-09-15-acp-eof-output.zh-CN.md)
## 问题与证据
已安装的 0.23.3 ACP 进程可能在输入 EOF 后退出 0,却留下不完整的
`available_commands_update` 帧。受控客户端在帧开始后暂停读取 101 毫秒,随后
持续读取至 stdout 结束。实际 ACP 进程自然退出,留下 196,608 字节的未终止
片段,没有发送清理信号。测试使用安装内的 Node 和 CLI 绕过包装器,确保捕获
的是 ACP 自身退出。此前未等待完整 stdout 的脚本结果仅保留为历史观察。
SDK 在输入结束时完成 `connection.closed`,不等待私有写队列排空。CLI 清理后
调用 `process.exit`。Web writer 写入成功也不足以证明完成:Node 的
`Writable.toWeb` 在未触发背压时可以先于原生写回调完成。成功退出前需要等待
原生输出 finish。
## 决策与边界
在 `acp-output.ts` 为 `runAcpAgent` 提供私有输出所有者。它持有
`Writable.toWeb(process.stdout)` 的 writer,向 NDJSON 提供带准入检查的字节流。
普通写入直接返回底层写 promise。EOF 清理后同步停止接收新帧,再关闭底层
writer。原生 end/finish 排在带准入检查的流的 sink 已转交给内层 writer
`write()` 的帧之后;后续 sink 回调拒绝写入,不再进入 stdout。
关闭操作复用同一个 promise,保持幂等。
等待 writer 的 `closed` promise,保留原始写入/finish 错误,即使 errored
stream 的 `close()` 只报告状态错误。输出排空最多等待两秒,与普通单项清理
预算一致。超时则拒绝并直接销毁原生输出,不等待可能排在阻塞写入之后的 Web
abort。操作结束后释放定时器和 writer 锁。
报告 EOF 清理及输出错误,两者同时发生时聚合。排空失败的报告方式属于可观察
行为变化:Linux 评审中的基线 `a98711330c` 在永久停读时输出被截断却退出 0,
在读端关闭时记录 EPIPE 但仍退出 0;`cc0f7c6949` 则分别携带排空超时或原始
EPIPE 退出 1。这是失败报告的改善,不是保留基线退出码。通过关闭读管道断开的
IDE 或嵌入式客户端可能观察到新的退出码 1。
[Linux A/B 报告](https://github.com/QwenLM/qwen-code/pull/11916#issuecomment-5677832569)
记录了这些测量;本次文档修正没有重跑。
排空期间保留信号处理器。现有 SIGTERM/SIGINT 的 destroy-and-exit 路径继续
生效,不将其报告为正常排空。不修改会话 disposal、命令列表内容、SDK 队列、信号期限或
协议字段。
保证范围仅包含封口前已转交给内层 writer 的帧:完整写完,否则报告排空失败。
仍在 SDK、NDJSON 或带准入检查的 Web stream 中排队的调用尚未跨过这个边界,
即使调用方在 EOF 前已经发出。它们之后可能被 `ACP output is closed` 拒绝,
由 SDK 记录错误并丢弃,而 ACP 仍退出 0。
[沙箱报告](https://github.com/QwenLM/qwen-code/pull/11916#issuecomment-5691411334)
在 `b8124075c6` 测得该残余行为:200 帧积压仅交付 93 个完整帧(已发出的
431,090 字节中收到 200,405 字节,46.5%),退出 0;其基线没有交付字节,
也退出 0。这是单个测试脚本的观察,不是交付比例保证。上游队列排空继续延后。
本修复不承诺 EOF 后仍在执行的每个入站 RPC 都收到回复,不停止 SDK 内部队列,
也不能向永久停止读取的对端保证交付。它独立于 #11866 保持行为的重构。
## 验证
用真实 Node Writable 验证低于背压阈值的小帧、大帧待完成写入、finish 失败、
原始写错误、迟到帧、重复关闭及永久停读。验证 EOF 集成顺序、清理失败保留和
信号交错。独立测试工程师对最终 bundle 重跑公共客户端复现,等待真实 ACP
退出及 stdout end/close,再检查完整 NDJSON 和进程/端口清理。
执行受影响测试、build、typecheck、bundle、lint,随后进行两轮无问题自审及
独立审查。原始复现和其他失败观察保留在
`.qwen/issues/issue-11866-acp-eof-output.md`。
## 最终验证结果
实现通过七项真实 Writable 测试、受影响的 ACP/CLI 测试、build、typecheck、lint
和 bundle 检查。三组已有传输 mock 测试在同一边界 mock 私有输出所有者:
`acpAgent.test.ts`、`acpAgent.worktree.test.ts` 和 `plan-mode-config.test.ts`。
第一组已有该 mock 后,为后两组补齐了 mock;后两组的首次失败及调整后四项通过
结果均保留。两轮无问题自审和
独立审查未发现未决源码缺陷。
首轮最终组合 bundle 通过两种直接 ACP EOF 场景:收到首个帧片段后 EOF,以及收到
完整帧后 EOF。两个实际 ACP 进程均自然退出 0,收到完整的 1,058,795 字节命令
帧及全部 524,288 字节 fixture。仅归一化会话 UUID 和精确临时目录后,两帧
一致。全局基线的命令目录不同,因此不宣称与已安装 CLI 的完整命令目录一致。
永久停读导致实际 ACP 在 2,021 毫秒后退出 1,保留原始排空超时错误。关闭读管道
导致实际 ACP 在 16 毫秒后退出 1,保留原始 EPIPE。原始脚本失败作为预期负例
结果保留。正例等待 stdout end/close;EPIPE 场景观察预期的读端关闭。独立检查
未发现所拥有的进程、进程组或监听端口残留。八个组合 E2E 样本执行前后,包含
1,079 个文件的 bundle 清单保持一致。
恢复已安装的 Ink 补丁并重新构建后,八个场景再次满足相同验收条件。两个正常
EOF 进程均退出 0,输出 1,062,289 字节 stdout;使用同样的归一化后,与此前
最终产物的输出逐字节一致。停读场景在 2,024 毫秒后退出 1,保留原始超时;
关闭管道场景在 13 毫秒后退出 1,保留 EPIPE。新的 1,079 文件清单在验证前后
一致,没有所拥有的进程或监听端口残留。`post-install-e2e-manifest.json`
另行记录第二份产物的结果。
可复现的 EOF 截断已在所述输出所有者边界内验证修复。不追溯认定曾崩溃或未捕获
实际退出的历史观察脚本的原因或退出码。组合变更的仓库级验证记录在
[完成设计](2026-09-15-acp-bridge-completion.zh-CN.md)中。

View file

@ -0,0 +1,137 @@
# ACP file watcher cleanup during process exit
[English](2026-09-15-acp-file-watcher-exit.md) |
[简体中文](2026-09-15-acp-file-watcher-exit.zh-CN.md)
## Problem and evidence
On macOS, shutting down an ACP process with 32 user skills can block inside
`fs.FSWatcher.close()`. libuv synchronously waits for FSEvents to rebuild its
remaining watch stream. The parent kills the child after its existing five-second
TERM grace, and the daemon exits 1 although the HTTP/SSE session flow succeeded.
Two runs of the installed CLI reproduced this, including one without diagnostic
instrumentation. A native stack and watcher trace identify the workspace watcher
close during Config shutdown. This evidence does not attribute all earlier
intermittent failures to the same operation.
A diagnostic probe preserved normal watcher closes before SIGTERM, then left
native handles for process exit. With 73 native watches, both ordinary session
closes completed and all 71 remaining handles were reclaimed on a successful
process exit. This establishes that Node can exit without explicitly closing
these watchers. The original reproduction now passes with the production fix;
full shutdown acceptance remains open as recorded below.
## Decision and scope
Keep native watching and ordinary watcher cleanup unchanged. Only when the ACP
CLI process is committed to exiting, stop forwarding watch events on macOS and
let the operating system reclaim its watch handles at process exit. This avoids
blocking subsequent writer, lock, hook, tool and subprocess cleanup. It does not
skip those cleanups, change their failure reporting, extend the parent deadline,
or enable polling.
This is a separate behavioral fix from the no-behavior-change bridge extraction
in #11866. Slow watcher closure while the process remains alive is outside this
fix. Windows and Linux still explicitly close watchers during exit.
## Ownership and implementation
`prepareFileWatchersForProcessExit()` sets process-local terminal state. It has
three call sites: the ACP signal handler before shutdown begins, ACP connection
EOF before cleanup, and the CLI's ACP finally block before exit cleanup (including
setup failure). The only production caller of `runAcpAgent` is the CLI entry,
which exits after this cleanup; signal handlers also explicitly exit. This
terminal state must never be set for individual session closes or by an embedded
runtime that will continue serving. It is intentionally irreversible.
`closeFileWatcher()` preserves synchronous close invocation, thrown errors and
the original close promise during normal operation. In terminal macOS cleanup it
detaches `all` (Chokidar) and `change` (native) listeners. Existing error listeners
remain attached so a late native error cannot become an unhandled EventEmitter
error. Handles remain open only until the already-planned process exit.
All consumers are explicit:
| Consumer | Watched data | Normal cleanup |
| -------------------- | --------------------------------------------- | --------------------------------- |
| SettingsWatcher | User/workspace settings directories and files | Replacement and stop |
| SkillManager | User/project skill directories | Removed watch targets and stop |
| ExtensionFileWatcher | Extension roots and bootstrap directory | Bootstrap promotion and stop |
| LspConfigWatcher | Workspace LSP configuration | Stop |
| CronScheduler | Durable task directory | Stop and relinquish participation |
Each retains its own timer, generation, subscription and lock cleanup. No Config
option, ACP wire field, parent process state or session ownership rule is added.
## Verification and acceptance
- Unit coverage: normal close invocation/promise/errors; macOS terminal event
suppression without native close; retained error handling; Linux/Windows close.
- CLI coverage: no terminal state before shutdown, state set before writer or
resource cleanup on signals and EOF, and setup-failure cleanup. Existing managed
writer failure and pending-initialization tests must still pass.
- Existing watcher and cron tests must continue proving ordinary stop/restart and
lock behavior. Build, typecheck and affected package tests are required.
- Re-run the 32-skill public daemon scenario with the local bundle, native watching,
no polling or preload. Require actual daemon exit 0, no forced child shutdown,
and no leftover process or listener. Also cover EOF and live-session shutdown.
- Audit the complete fix and independently review terminal-state ownership and
every watcher consumer. Do not claim a permanent fix until verification passes.
## Observed verification results before the process-query follow-up
On this macOS host, the local bundle with native watching and no diagnostic
preload or polling override passed the original 32-skill daemon scenario,
direct ACP EOF, and shutdown with two live sessions. The latter sealed both
writer records with matching transcript lengths and hashes. Independent checks
found no persistent process or listening port. EOF briefly retained a `ps`
descendant at 54 ms; it was gone by 215 ms.
Full acceptance remains open: an earlier live-session sample exited 1 because
the process-tree query had no parseable rows. The raw query output was not
captured, so this does not establish that stdout was empty or identify the
cause. A bounded diagnostic rerun captured 714 valid rows and exited 0; it did
not reproduce or explain that failure. Strict shutdown error reporting remains
unchanged. Successful samples do not invalidate the failed one.
Build, typecheck, bundle and targeted lint passed. The first affected test run
passed all 1,145 tests. An independent core rerun failed a 600 ms native-watcher
assertion and a cleanup-hook timeout; a targeted comparison using exact HEAD
scheduler source failed the same watcher assertion on both versions. The hook
timeout did not recur and remains unexplained. Five selected managed-shutdown
tests still reject failures and retain exit code 1. Two self-audit passes and an
independent review found no source defect. These results do not establish that
all tests are consistently green or that #11866 is complete.
The subsequent [process-query output fix](2026-09-15-acp-process-query-output.md)
reproduces and repairs a controlled timeout-related output-loss mechanism.
Native-watching 32-skill and active-writer shutdown checks also pass with that
fix. It does not retrospectively attribute the untraced query failure above.
A later EOF test captured a truncated notification; one bounded rerun passed,
leaving that observation unresolved. See the linked design for current evidence
and the limits of the scoped query-fix conclusion.
## Combined verification after the EOF fix
The later [EOF output fix](2026-09-15-acp-eof-output.md) establishes a controlled
truncation reproduction and verifies complete accepted frames before successful
exit. It does not assign a cause or exit code to earlier incomplete observations.
The final combined bundle again passed the native-watching 32-skill public flow
and shutdown with two active writers. Both daemons exited 0, both active records
were sealed with matching transcript lengths and hashes, and independent checks
found no owned process or listener remaining. Direct ACP EOF cases separately
recorded actual ACP exit 0; child absence in a daemon case is not a measured
child exit code.
After the installed Ink patch was restored and the bundle rebuilt, these same
native-watching and active-writer scenarios passed again, with actual daemon
exit 0 and matching sealed transcript lengths and hashes. All eight combined
E2E checks met their acceptance conditions with the new 1,079-file artifact
unchanged and no owned process or listener remaining.
Normal session DELETE requests still showed multi-second stalls
within the existing E2E deadline. No latency equivalence or new cause is claimed;
ordinary watcher-close performance remains outside this terminal-exit fix. The
untraced historical query failure and the earlier cron timing observations remain
recorded. Repository-wide results are maintained in the
[completion design](2026-09-15-acp-bridge-completion.md).

View file

@ -0,0 +1,113 @@
# ACP 进程退出时的文件监听器清理
[English](2026-09-15-acp-file-watcher-exit.md) |
[简体中文](2026-09-15-acp-file-watcher-exit.zh-CN.md)
## 问题与证据
在 macOS 上,关闭带有 32 个用户技能的 ACP 进程可能阻塞在
`fs.FSWatcher.close()` 中。libuv 会同步等待 FSEvents 重建剩余监听列表。
父进程在现有的五秒 TERM 等待到期后强杀子进程,导致 daemon 退出 1,即使
HTTP/SSE 会话流程成功。已安装的 CLI 两次复现,其中一次没有诊断插桩。
原生调用栈和监听器跟踪将阻塞定位到 Config shutdown 中关闭 workspace 监听器。
这些证据不能认定此前所有间歇性失败都发生在同一操作。
诊断探针保留 SIGTERM 前的正常监听器关闭,此后将原生句柄交给进程退出回收。
在 73 个原生监听器的场景中,两个普通会话均正常关闭,剩余 71 个句柄随成功
退出的进程一起回收。这证明 Node 可以在未显式关闭这些监听器时退出。
生产修复已通过原始复现场景;完整关闭验收仍有未决项,见下文。
## 决策与范围
保持原生监听和普通监听器清理不变。仅在 ACP CLI 进程确定退出后,在 macOS
停止转发监听事件,由操作系统在进程退出时回收监听句柄,避免阻塞后续的写入器、
锁、hook、工具和子进程清理。不跳过这些清理,不修改其失败报告,不延长父进程
等待时间,也不启用轮询。
这是独立于 #11866 无行为变化 bridge 抽取的行为修复。进程继续运行时的监听器
关闭缓慢不在本次修复范围内。Windows 和 Linux 退出时仍显式关闭监听器。
## 所有权与实现
`prepareFileWatchersForProcessExit()` 设置进程内终止状态,有三处调用:ACP
信号处理器开始关闭前、ACP 连接 EOF 开始清理前,以及 CLI 的 ACP finally
块执行退出清理前(包含初始化失败)。`runAcpAgent` 唯一的生产调用者是 CLI
入口,它会在清理后退出;信号处理器也会显式退出。单个会话关闭或仍将继续服务
的嵌入式 runtime 绝不能设置这一状态。此状态有意不可逆。
`closeFileWatcher()` 在正常运行时保留同步 close 调用、同步异常和原 close
promise。在 macOS 最终退出清理中,它移除 `all`(Chokidar)和 `change`
(原生)监听回调,保留已有错误回调,防止迟到的原生错误变成未处理的
EventEmitter 错误。句柄仅保留到已确定发生的进程退出。
全部消费者如下:
| 消费者 | 监听数据 | 正常清理 |
| -------------------- | ------------------------- | ------------------ |
| SettingsWatcher | 用户/工作区设置目录和文件 | 替换监听器和停止 |
| SkillManager | 用户/项目技能目录 | 移除监听目标和停止 |
| ExtensionFileWatcher | 扩展根目录和引导目录 | 引导升级和停止 |
| LspConfigWatcher | 工作区 LSP 配置 | 停止 |
| CronScheduler | 持久化任务目录 | 停止并退出参与 |
每个消费者保留自身的定时器、generation、订阅和锁清理。不新增 Config 选项、
ACP wire 字段、父进程状态或会话归属规则。
## 验证与验收
- 单测覆盖:正常 close 调用/promise/异常;macOS 最终退出时禁止事件转发且
不进入原生 close;保留错误处理;Linux/Windows 仍关闭监听器。
- CLI 覆盖:关闭前没有终止状态;信号和 EOF 路径在写入器或资源清理前设置
状态;初始化失败清理。现有 managed 写入失败和未完成初始化测试必须仍通过。
- 现有监听器与 cron 测试继续验证普通停止/重启和锁行为。执行 build、typecheck
和受影响包测试。
- 使用本地 bundle、原生监听、无轮询和 preload 重跑 32 个技能的公共 daemon
场景。要求 daemon 实际退出 0、没有强杀子进程、没有残留进程或监听端口。
同时覆盖 EOF 和仍有活动会话的退出。
- 自审完整修复,独立审查终止状态所有权及每个监听器消费者。验证通过前不宣称
永久修复完成。
## 进程查询后续修复之前的验证结果
在当前 macOS 主机上,本地 bundle 使用原生监听、没有诊断 preload 或轮询覆盖,
通过了原始 32 个技能的 daemon 场景、ACP 直接 EOF,以及保留两个活动会话的
关闭场景。后者正确封存两个 writer 记录,记录长度和哈希与 transcript 一致。
独立检查未发现持续残留的进程或监听端口。EOF 后 54 毫秒时短暂存在一个 `ps`
后代进程,215 毫秒时已消失。
完整验收仍未通过:此前一次活动会话样本因为进程树查询没有可解析行而退出 1。
该样本没有捕获原始查询输出,因此不能认定 stdout 为空,也不能确定原因。
一次有界诊断重跑采到 714 行有效数据并退出 0,没有复现或解释此前失败。
严格的关闭错误报告保持不变。成功样本不能推翻已记录的失败。
Build、typecheck、bundle 和定向 lint 通过。首次受影响测试共 1,145 项全部通过。
独立 core 重跑出现一项 600 毫秒原生监听断言失败和一项清理 hook 超时;使用精确
HEAD scheduler 源码的定向对照,在两个版本上均出现同一监听断言失败。hook
超时没有再次出现,原因仍未确定。五项 managed 关闭专项测试继续拒绝失败并保留
退出码 1。两轮自审和独立审查未发现源码缺陷。这些结果不代表测试持续全绿,
也不代表 #11866 已完成。
后续的[进程查询输出修复](2026-09-15-acp-process-query-output.zh-CN.md)复现并
修复了一种受控的超时输出丢失机制。加入该修复后,原生监听下的 32 个技能及
活动 writer 关闭检查也通过。这不能追溯认定上述未插桩查询失败的原因。
后续一次 EOF 测试捕获到通知截断,一次有界复查通过,该观察仍未解释。
当前证据及查询修复结论的范围详见链接设计。
## EOF 修复后的组合验证
后续的 [EOF 输出修复](2026-09-15-acp-eof-output.zh-CN.md)建立了受控的截断
复现,并验证成功退出前写完已接收帧。不为此前不完整的观察追溯指定原因或退出码。
最终组合 bundle 再次通过原生监听的 32 个技能公共流程及两个活动 writer 的关闭。
两个 daemon 均退出 0,两个活动记录均正确封存且长度、哈希与 transcript 一致,
独立检查未发现所拥有的进程或监听端口残留。直接 ACP EOF 场景另行记录了实际
ACP 退出 0;daemon 场景中的子进程消失不等于测得子进程退出码。
恢复已安装的 Ink 补丁并重新构建 bundle 后,同样的原生监听及活动 writer
场景再次通过,实际 daemon 退出 0,封存的 transcript 长度和哈希一致。
八个组合 E2E 检查均满足各自验收条件,新的 1,079 文件产物保持不变,
没有所拥有的进程或监听端口残留。
普通会话 DELETE 请求仍出现数秒的停顿,但在已有 E2E 期限内完成。
不宣称延迟等价或新的因果关系;普通监听器关闭性能仍不属于本次最终退出修复。
未插桩的历史查询失败及此前 cron 时序观察继续保留。仓库级结果记录在
[完成设计](2026-09-15-acp-bridge-completion.zh-CN.md)中。

View file

@ -0,0 +1,117 @@
# Preserve ACP process-query output after event-loop stalls
[English](2026-09-15-acp-process-query-output.md) |
[简体中文](2026-09-15-acp-process-query-output.zh-CN.md)
## Problem and evidence
Node 22's `execFile` timeout destroys stdout and stderr before signaling the
command. When the parent event loop stalls past the timeout, a command that
already exited 0 can therefore return a successful callback with lost output.
For a process-tree snapshot this either fails parsing or risks an incomplete
ownership table.
A real `/bin/ps` test reproduced empty successful output in all six blocked
samples, including the existing 2,000 ms timeout; all six unstalled controls
returned valid rows. Injecting a 2,250 ms parent stall immediately after the
first daemon process-query launch reproduced the product error: public session
behavior and both writer seals succeeded, ACP exited 0, but daemon exited 1
with no parseable process-table rows. Its trace confirms pipe destruction before
the successful empty callback. This establishes a controlled failure mechanism;
it does not retrospectively prove the cause of the uninstrumented `WecQ2w` run.
## Decision and scope
Only POSIX process-table queries replace the built-in `execFile` timeout with a
same-duration timer that sends SIGTERM without destroying output pipes. Keep
`execFile` for buffering, UTF-8 decoding, its 8 MiB limit and error handling.
Read the complete callback result before parsing. An already completed successful
command can now supply its buffered output; a command terminated by the timer
still fails. Timer callback exceptions reject the query. Clear the timer on
settlement, including synchronous launch errors.
Keep the existing 1–2,000 ms query signal-timer clamp, 5-second ACP TERM grace,
10-second teardown deadline, process ownership checks and failure reporting.
The query timer sends SIGTERM; it does not independently settle the query
promise. If the query child ignores SIGTERM and never closes, that await can
remain pending despite the surrounding teardown deadline. A forced query
settlement or escalation policy is deferred; the observed timeout samples exited
on SIGTERM and do not establish a hard bound for a signal-ignoring child.
Do not retry an empty table or treat one as success. Windows `taskkill` and
synchronous process queries keep their existing implementation. No new public
option, route, wire field or dependency is added.
## Consumers and boundaries
`queryProcessTable` serves the initial and escalation ownership snapshots, plus
terminal-group verification. All remain process-local and operate on the same
tracked child and known process groups. Query errors retain the existing initial
snapshot failure or conservative live-group handling; no primary-runtime fallback
is introduced.
The registry's callers are the ACP spawn-channel factory (including the default
bridge factory and the daemon's shared registry), the external ACP subagent
executor, and the external Codex subagent executor. They keep their reservation,
signal, drain and ownership contracts. This behavioral fix is separate from the
#11866 bridge extraction and the macOS watcher-exit change.
## Verification and limitations
- Require the unchanged stall-injection daemon scenario to exit 0 with complete
process-table data, valid writer seals, and no persistent process or listener.
- Verify native-watching daemon shutdown without injection and direct ACP EOF.
- Cover timeout signaling at 2,000 ms, complete descendant discovery after a late
callback, timer cleanup, timeout signal errors and synchronous kill errors.
- Preserve rejection of missing commands, empty/malformed tables, buffer overflow,
nonzero exit and signal exit. Keep Windows taskkill assertions unchanged.
- Build, typecheck, bundle, targeted lint, registry/spawn-channel tests and external
executor tests; then self-audit and independent review.
The unchanged microtask-injection scenario reproduces daemon exit 1 before the
fix and exit 0 after it. The replacement timer actually fires after the stall;
both pipes remain open and the callback receives all 16,560 captured bytes
(720 parseable rows). Public observations match, both writer seals are valid,
and independent process/group/port checks find no remaining resources. Real
subprocess timeout and exit-7 injections still reject the registry operation
while cleaning up the owned child.
Native-watching runs pass both the original 32-skill daemon flow and shutdown
with two active writer sessions. One direct EOF run captured a truncated late
notification and crashed the harness before recording the ACP exit code. A
single improved observer rerun records ACP exit 0 with complete JSON lines;
this does not explain or erase the first observation.
At the original query-output-fix stage on 2026-09-15, build, typecheck, bundle
and targeted lint passed. That stage's four affected test files passed 200 tests:
37 registry and 47 spawn-channel tests in
`query-fix-final-bridge-tests-after-build.log`, plus 116 external-executor tests
in `query-fix-cli-tests.log`, under the shutdown investigation directory.
These are historical run counts, not counts of the current suites after later
main integrations. An initial spawn-channel run could not collect while a
concurrent build removed its workspace dependency; the serial rerun after that
build passed. A mutation that removed timer cleanup failed the new
early-settlement regression test. Two final self-audit passes and independent
source review found no outstanding defect at that stage.
The controlled query-output defect is verified fixed on macOS with Node
v22.22.2. This does not make JavaScript timers preempt synchronous work or fix
that work's latency. The uninstrumented historical shutdown failure, EOF tail
observation and cron watcher's baseline timing limitation remain separate
unresolved observations. This is not completion of all #11866 work.
## Subsequent combined verification
The [EOF output fix](2026-09-15-acp-eof-output.md) separately reproduces and
repairs output truncation. It does not retrospectively explain the observer
crash or the uninstrumented query failure. The final combined bundle passed
native 32-skill and active-writer shutdown. Real subprocess negative cases still
reject: `/bin/sleep 10` received the bounded SIGTERM after 2,001 ms with pipes
open, and a real Node process exiting 7 retained its failure. Both owned target
children were cleaned up; independent checks found no remaining resources.
After restoring the installed Ink patch and rebuilding, both query negative
cases met the same acceptance conditions again. The timeout sent SIGTERM after
2,003 ms with both pipes open; exit 7 still rejected. Registry counts returned
to zero and independent checks found no remaining resources. The rebuilt
1,079-file artifact remained unchanged throughout all eight combined E2E checks.
Full combined acceptance is tracked in the
[completion design](2026-09-15-acp-bridge-completion.md).

View file

@ -0,0 +1,94 @@
# 在事件循环阻塞后保留 ACP 进程查询输出
[English](2026-09-15-acp-process-query-output.md) |
[简体中文](2026-09-15-acp-process-query-output.zh-CN.md)
## 问题与证据
Node 22 的 `execFile` 超时会先销毁 stdout 和 stderr,再向命令发送信号。
父进程事件循环阻塞超过期限时,已经退出 0 的命令也可能通过成功回调返回丢失
数据的输出。进程树快照因此可能无法解析,或产生不完整的所有权表。
真实 `/bin/ps` 测试的六个阻塞样本均返回成功但为空的输出,其中包括现有的
2,000 毫秒超时;六个不阻塞的对照均返回有效行。在 daemon 首次启动进程查询后
立即注入 2,250 毫秒的父循环阻塞,复现了产品错误:公共会话行为和两个 writer
封存均成功,ACP 退出 0,但 daemon 因进程表没有可解析行而退出 1。跟踪确认
输出管道先被销毁,随后才发生成功的空回调。这证明了受控失败机制,不能追溯
认定没有插桩的 `WecQ2w` 样本也是同一原因。
## 决策与范围
仅对 POSIX 进程表查询,用相同时限的自有定时器替换 `execFile` 内置超时。
到期发送 SIGTERM,但不销毁输出管道。继续使用 `execFile` 的缓冲、UTF-8
解码、8 MiB 上限和错误处理,读取完整回调结果后再解析。已成功结束的命令
可以交付缓冲中的输出;被定时器终止的命令仍然失败。定时器回调异常会拒绝查询。
查询结束后清理定时器,也覆盖同步启动错误。
保持现有的 1–2,000 毫秒查询发信号定时器范围、ACP 五秒 TERM 等待、十秒清理
期限、进程所有权检查和失败报告。查询定时器只发送 SIGTERM,不独立完成查询
promise。如果查询子进程忽略 SIGTERM 且一直不关闭,该 await 即使经过外层
清理期限也可能继续挂起。强制完成查询或升级信号的策略延后处理;已观察的超时
样本都因 SIGTERM 退出,不能证明忽略信号的子进程也有硬性等待上限。
不重试空表,不将空表当作成功。Windows `taskkill` 和
同步进程查询保留原实现。不新增公共选项、路由、协议字段或依赖。
## 消费者与边界
`queryProcessTable` 用于初始及升级信号前的所有权快照,以及终态进程组验证。
它们仍是进程内操作,使用原有被跟踪子进程及已知进程组。查询错误仍保留初始
快照失败语义或将未证明终止的组视为存活,不新增主 runtime 回退。
Registry 的调用者是 ACP 子进程通道工厂(包括默认 bridge 工厂及 daemon
共享 registry)、外部 ACP 子代理执行器和外部 Codex 子代理执行器。它们的
预留、信号、清理及所有权契约保持不变。这项行为修复独立于 #11866 的 bridge
抽取及 macOS watcher 退出改动。
## 验证与局限
- 原样重跑带阻塞注入的 daemon 场景,要求退出 0、进程表数据完整、writer
封存有效,且没有持续残留的进程或监听端口。
- 验证不注入的原生监听 daemon 关闭,以及直接 ACP EOF。
- 覆盖 2,000 毫秒到期信号、迟到回调后的完整后代发现、定时器清理、查询
信号错误和 kill 同步异常。
- 保留命令缺失、空表、非法表、缓冲溢出、非零退出及信号退出的拒绝语义。
Windows taskkill 断言保持不变。
- 执行 build、typecheck、bundle、定向 lint、registry/通道启动测试和外部
执行器测试,然后自审和独立审查。
原样重跑的微任务阻塞注入场景,在修复前复现 daemon 退出 1,修复后退出 0。
替代定时器在阻塞结束后实际触发,两条管道仍保持打开,回调收到捕获的全部
16,560 字节(720 行可解析数据)。公共行为一致,两个 writer 封存有效,独立
进程、进程组和端口检查未发现残留资源。真实子进程超时及退出 7 的故障注入仍
使 registry 操作失败,同时完成所拥有子进程的清理。
原生监听下,原始 32 个技能的 daemon 流程和保留两个活动 writer 会话的关闭
均通过。一次直接 EOF 测试捕获到迟到通知被截断,测试脚本在记录 ACP 退出码前
崩溃。仅一次改进观察脚本的复查记录了 ACP 退出 0 且 JSON 行完整;这没有解释
或消除首次观察到的问题。
在 2026-09-15 最初的查询输出修复阶段,build、typecheck、bundle 和定向 lint
通过。该阶段四个受影响测试文件共 200 项通过:shutdown 调查目录下的
`query-fix-final-bridge-tests-after-build.log` 记录 registry 37 项和通道启动
47 项,`query-fix-cli-tests.log` 记录外部执行器 116 项。这是历史运行计数,
不是后续合入 main 后当前套件的计数。一次通道启动测试因并行构建移除工作区
依赖而无法收集;该构建完成后串行重跑通过。移除定时器清理的 mutation 使
新增的提前完成回归测试正确失败。该阶段最终两轮自审和独立源码审查未发现
未决缺陷。
受控的查询输出丢失缺陷已在 macOS、Node v22.22.2 上验证修复。这不会使
JavaScript 定时器能够抢占同步工作,也不修复该工作的延迟。没有插桩的历史
关闭失败、EOF 尾部通知及 cron 监听的基线时序局限仍作为独立未决观察保留。
这不代表 #11866 全部工作已完成。
## 后续组合验证
[EOF 输出修复](2026-09-15-acp-eof-output.zh-CN.md)另行复现并修复输出截断。
它不追溯解释观察脚本崩溃或未插桩的查询失败。最终组合 bundle 通过原生监听下
32 个技能及活动 writer 的关闭。真实子进程负例继续拒绝:`/bin/sleep 10` 在
2,001 毫秒后收到有界 SIGTERM,此时管道保持打开;实际退出 7 的 Node 进程
保留失败。两个所拥有的目标子进程均已清理,独立检查未发现残留资源。
恢复已安装的 Ink 补丁并重新构建后,两个查询负例再次满足相同验收条件。
超时在 2,003 毫秒后发送 SIGTERM,两条管道保持打开;退出 7 仍然拒绝。
Registry 计数恢复为零,独立检查未发现残留资源。重新构建的 1,079 文件产物
在八个组合 E2E 检查期间保持不变。
完整组合验收记录在[完成设计](2026-09-15-acp-bridge-completion.zh-CN.md)中。

View file

@ -0,0 +1,47 @@
# Goal telemetry
[English](2026-09-15-goal-telemetry.md) | [简体中文](2026-09-15-goal-telemetry.zh-CN.md)
## Problem
A Goal runs autonomously, and the ways it stops — the verifier completing or blocking it, a token, turn or active-time budget running out, the no-progress bound pausing it, the user pausing or clearing it — are recorded only in the session transcript. The telemetry package has no Goal event and no Goal metric. An operator who wants to know how often Goals end blocked, how much a completed Goal spends, or whether a budget is set too low has to read transcripts one at a time.
Codex counts goal outcomes in its metrics, and Claude Code emits `tengu_goal_*` analytics events. Neither carries the objective text.
## Design
**One event, distinguished by cause.** Every reported transition is a `qwen-code.goal_state` event whose `cause` is the runtime's own `GoalStateCause`. A separate event per transition would each need a type, a logger, an analytics-sink method and a documentation entry while carrying the same fields.
**Which causes are reported.** `create`, `replace`, `edit`, `pause`, `resume`, `clear`, `complete`, `blocked`, `usage_limited` and `verifier_reject`: the user's controls and the stops a user acts on. `turn_finished` and `checkpoint` happen on every turn; they would multiply the volume, and per-turn token spend is already in the API metrics. `migrated` is a one-off format migration. `verifier_accept` is always followed by the `complete` or `blocked` it accepted. Broadcasts without a cause are activity changes inside the runtime.
**Where it is wired.** `Config.initializeGoalRuntime` subscribes to the runtime it has just created. It is the one place that holds both the runtime and the `Config` every telemetry logger takes, and it subscribes before the restore it starts, so no transition can commit with nobody listening. The mapping from a snapshot to an event is a pure function in the telemetry package, `goalStateEventFromSnapshot`.
**Restored state is not reported again.** `restore()` republishes a resumed session's Goal with the cause of the record it recovered: a paused Goal comes back as a `pause` broadcast, a budget-stopped Goal as `usage_limited`. A subscriber cannot tell that broadcast from the live transition that first produced it, and comparing against `getRecoveryCause()` is ambiguous as soon as a later live transition carries the same cause. The runtime therefore passes a third broadcast argument, `meta`, with `replayed: true` on that one broadcast and nothing on any other, and the telemetry subscriber skips replays. Existing listeners that take two arguments are unaffected.
**No free text.** The event carries identifiers, enums and numbers: the Goal id and revision, the resulting status, the limit kind, the turn count and turn budget, tokens used and the token budget, active time and the active-time budget, and the objective's length in code points. The objective, the stop reason and the checkpoint failure message are never included. `telemetry.logPrompts`, the switch that gates prompt text elsewhere, defaults to on, so gating the objective behind it would include it by default. An active Goal's committed `activeTimeMs` lags the clock, so the event reads it as of the broadcast.
**Metrics stay bounded.** `qwen-code.goal.transition.count` counts every event, tagged by `cause`, `status` and `limit_kind`. On `complete`, `blocked` and `usage_limited`, the histograms `qwen-code.goal.tokens_used` and `qwen-code.goal.turn_count` record what the Goal had spent, tagged by `cause` and `limit_kind`. The Goal id stays on the log record: every Goal is a new value, which is the unbounded time-series fan-out the metrics module already declines for `session.id` by default. A resumed Goal contributes one observation per stop, with lifetime cumulative spend and turns. Histogram `_count` counts stops rather than distinct Goals, and `_sum` is not total Goal spend.
**Both sinks.** The log record goes to OpenTelemetry when the SDK is initialized, and the analytics sink receives the event when usage statistics are enabled, as with every other event. The analytics payload leaves out the Goal id, because that sink aggregates by installation.
## Scope
- `goal-protocol.ts`: `GoalBroadcastMeta`.
- `goal-runtime.ts`: the optional `meta` argument on `subscribe` listeners and on `broadcast`, set on the restore broadcast.
- `telemetry/types.ts` and `constants.ts`: `GoalStateEvent`, `GOAL_STATE_EVENT_CAUSES`, `makeGoalStateEvent`, `EVENT_GOAL_STATE`.
- `telemetry/goal-events.ts`: `goalStateEventFromSnapshot`.
- `telemetry/loggers.ts`, `qwen-logger/qwen-logger.ts`, `metrics.ts` and `index.ts`: `logGoalState`, `logGoalStateEvent`, `recordGoalStateMetrics` and the three instruments.
- `config/config.ts`: the subscriber.
- Documentation: the telemetry reference and the Goals feature page.
Not changed: the per-stream Goal state subscription in `client.ts` that feeds the UI, Goal proposal approvals, and spans for Goal turns.
## Verification
- `goal-events.test.ts`: every reported cause maps to an event and every unreported cause does not; figures are carried and absent ones are left out rather than set to `undefined`; an active Goal's time is read as of the broadcast; the objective length counts code points; `clear` names the removed Goal and carries no figures; no objective or reason text appears in the serialized event.
- `goal-runtime.test.ts`: the restore broadcast carries `meta.replayed`, and a later live transition carries no `meta`.
- `loggers.test.ts`: the log record's body and attributes, the metrics call, and the analytics sink still being called when the OpenTelemetry SDK is off.
- `qwen-logger.test.ts`: the analytics event leaves out the Goal id and absent figures.
- `metrics.test.ts`: the instruments are registered; the counter carries only its three bounded attributes; the histograms record only on the three outcomes.
- `config.test.ts`: a created Goal is reported once; a resumed session's recovered Goal is not reported, and the user's next control on it is.
- End to end: a headless run writing telemetry to a file before and after the change, and a resumed session that reports no second transition for the recovered Goal.

View file

@ -0,0 +1,47 @@
# Goal 遥测
[English](2026-09-15-goal-telemetry.md) | [简体中文](2026-09-15-goal-telemetry.zh-CN.md)
## 问题
Goal 是自主运行的,它停下来的方式——verifier 判定完成或 blocked、token/轮数/活跃时长预算耗尽、空转上限触发 pause、用户 pause 或 clear——只记录在会话 transcript 里。遥测包里既没有 Goal 事件,也没有 Goal 指标。运维人员想知道 Goal 有多大比例以 blocked 结束、一个完成的 Goal 花了多少、某个预算是否设得太低,只能逐个翻 transcript。
Codex 在指标里统计 goal 结果,Claude Code 发出 `tengu_goal_*` 分析事件。两者都不携带目标文本。
## 设计
**一个事件,用 cause 区分。** 每次上报的状态转换都是一个 `qwen-code.goal_state` 事件,其 `cause` 就是运行时自身的 `GoalStateCause`。如果每种转换单独一个事件,每个都要一个类型、一个 logger、一个分析 sink 方法和一条文档,而字段完全相同。
**上报哪些 cause。** `create`、`replace`、`edit`、`pause`、`resume`、`clear`、`complete`、`blocked`、`usage_limited` 和 `verifier_reject`:用户的控制操作,以及用户需要处理的停止。`turn_finished` 与 `checkpoint` 每轮都会发生,会成倍放大事件量,而每轮的 token 花费已经在 API 指标里。`migrated` 是一次性的格式迁移。`verifier_accept` 之后总会紧跟它所接受的 `complete` 或 `blocked`。没有 cause 的广播是运行时内部的活动状态变化。
**接线位置。** `Config.initializeGoalRuntime` 订阅它刚创建的运行时。这是唯一同时持有运行时和每个遥测 logger 所需 `Config` 的地方;并且订阅发生在它启动 restore 之前,所以不存在状态转换已提交却没有监听者的窗口。从快照到事件的映射是遥测包里的纯函数 `goalStateEventFromSnapshot`。
**恢复出来的状态不重复上报。** `restore()` 会以所恢复记录的 cause 重新发布续跑会话的 Goal:一个 paused 的 Goal 以 `pause` 广播回来,一个因预算停下的 Goal 以 `usage_limited` 回来。订阅者无法把这次广播和最初产生它的那次实时转换区分开;而与 `getRecoveryCause()` 比较,一旦之后的实时转换带着相同 cause 就会产生歧义。因此运行时给广播加第三个参数 `meta`,只在这一次广播上传 `replayed: true`,其余广播都不传;遥测订阅者跳过重播。只接收两个参数的现有监听者不受影响。
**不带自由文本。** 事件只携带标识、枚举和数字:Goal id 与 revision、转换后的状态、限制类型、轮数与轮数预算、已用 token 与 token 预算、活跃时长与活跃时长预算,以及目标按码点计的长度。目标、停止原因和 checkpoint 失败信息一律不包含。别处用来控制 prompt 文本的开关 `telemetry.logPrompts` 默认开启,所以把目标放在它后面就等于默认上报。active 状态的 Goal 已提交的 `activeTimeMs` 落后于时钟,所以事件按广播时刻读取。
**指标保持有界。** `qwen-code.goal.transition.count` 统计每个事件,属性为 `cause`、`status` 和 `limit_kind`。在 `complete`、`blocked` 和 `usage_limited` 时,直方图 `qwen-code.goal.tokens_used` 与 `qwen-code.goal.turn_count` 记录 Goal 到此为止的花费,属性为 `cause` 和 `limit_kind`。Goal id 只留在日志记录上:每个 Goal 都是一个新值,这正是指标模块默认对 `session.id` 所拒绝的无界时间序列膨胀。 恢复后的 Goal 每次停止都会贡献一个观测值,其中花费和轮数按整个 Goal 生命周期累计。因此直方图 `_count` 统计停止次数而非不同 Goal 的数量,`_sum` 不能作为 Goal 的总花费。
**两个 sink。** 与其他所有事件一样,OpenTelemetry SDK 初始化时日志记录发往 OpenTelemetry,启用使用统计时分析 sink 收到该事件。分析 sink 的载荷不含 Goal id,因为该 sink 按安装实例聚合。
## 范围
- `goal-protocol.ts`:`GoalBroadcastMeta`。
- `goal-runtime.ts`:`subscribe` 监听器与 `broadcast` 上可选的 `meta` 参数,在 restore 广播上设置。
- `telemetry/types.ts` 与 `constants.ts`:`GoalStateEvent`、`GOAL_STATE_EVENT_CAUSES`、`makeGoalStateEvent`、`EVENT_GOAL_STATE`。
- `telemetry/goal-events.ts`:`goalStateEventFromSnapshot`。
- `telemetry/loggers.ts`、`qwen-logger/qwen-logger.ts`、`metrics.ts` 与 `index.ts`:`logGoalState`、`logGoalStateEvent`、`recordGoalStateMetrics` 以及三个指标。
- `config/config.ts`:订阅者。
- 文档:遥测参考文档与 Goal 功能页。
不改动:`client.ts` 里为 UI 提供数据的按流 Goal 状态订阅、Goal 提案审批、Goal 轮的 span。
## 验证
- `goal-events.test.ts`:每个上报的 cause 都映射为事件,每个不上报的 cause 都不产生事件;数值字段被携带,缺失的字段直接省略而不是设为 `undefined`;active Goal 的时长按广播时刻读取;目标长度按码点计数;`clear` 给出被移除的 Goal 且不带数值;序列化后的事件里不出现目标或原因文本。
- `goal-runtime.test.ts`:restore 广播带 `meta.replayed`,之后的实时转换不带 `meta`。
- `loggers.test.ts`:日志记录的 body 与属性、指标调用,以及 OpenTelemetry SDK 关闭时分析 sink 仍被调用。
- `qwen-logger.test.ts`:分析事件不含 Goal id 与缺失的数值字段。
- `metrics.test.ts`:三个指标已注册;计数器只带三个有界属性;直方图只在三种结果时记录。
- `config.test.ts`:新建的 Goal 上报一次;续跑会话恢复出来的 Goal 不上报,用户对它的下一次控制操作会上报。
- 端到端:改动前后各跑一次把遥测写入文件的 headless 运行,并验证续跑会话不会为恢复出来的 Goal 再上报一次转换。

View file

@ -0,0 +1,78 @@
# Named-workflows-only lock
[中文](./2026-09-17-workflow-name-only.zh-CN.md)
Tracking issue: #11013, item 12 (remaining part).
Status: implemented in #12078.
## Problem
A deployment can let the model start the workflows an extension ships (`whenToUse`, #11957) and can scope approvals to a workflow by name or path (#11943). It had no way to stop the same model from writing an inline script of up to 1,000 agents and running it. An inline script has no name or path to write a permission rule against, so once a deployment relaxed approvals for its named workflows, inline scripts went through as well.
## Decisions
### The switch
`tools.workflowNameOnly` (default `false`) or `QWEN_CODE_WORKFLOW_NAME_ONLY=1` turns the lock on. `Config.isWorkflowNameOnly()` decides it once, when the session starts: the Workflow tool builds its description and parameter schema at startup, and a lock that changed under them would leave the model holding a contract the tool no longer honours.
A workspace may turn the lock on but not off. The key is a tighten-only setting: a workspace `true` is honoured, and a workspace `false` under an operator's `true` is dropped with a warning. The environment variable is excluded from project `.env` files, so repository content cannot unset an operator's exported value.
### What is locked
| Source | Locked session | Why |
| ------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `script` | refused | Code the model writes in the session has no identity a rule can match. |
| `scriptPath` | refused | Every saved workflow is reachable by name; a path adds nothing a name cannot say, and the generated-scripts root is a runtime directory. |
| `name`, and `name` with `resumeFromRunId` | allowed | A `Workflow(name:…[,sha256:…])` rule matches it. |
| `workflow({ scriptPath })` inside a script the model started | refused | The same rule, one level down. `workflow('<name>')` still works. |
A source counts the way parameter validation counts it: an empty `script` or `scriptPath` beside a `name` does not refuse a call that runs by name.
### The lock constrains the model, not the host
The refusal sits in the tool's `build`, the entry every model and client call takes, including the interactive slash command's `schedule_tool`. Runs a host starts over ACP — `run-saved`, `run-script`, retry and rerun — go through `buildSessionOwnedBackground`, which shares parameter validation but not `build`, and are not restricted. The nested refusal follows the same line: the tool asks the runner for it only on a call it built for the model or a client, so a host script may still nest by path.
### What the model is told
A locked session tells the model the rule instead of letting it find the rule by failing:
- The parameter schema omits `script` and `scriptPath`. `name` is not made `required`, because the host's runs validate against the same schema and start from a script.
- The description replaces the authoring pointer with a "Named workflows only" section, and its shared decision and runtime text lose the sentences that send the model to `scriptPath` or to editing a persisted script. A test holds that no such advice survives outside the section.
- The `workflow` keyword reminder steers toward `{ name, args }` instead of authoring a script. The lock is read before anything else in that path can fail.
- The interactive `/<name>` command dispatches by name.
### Resuming by name
Outside the lock, resume calls keep naming the script path, so existing `Workflow(scriptPath:…)` grants keep matching. In a locked session a path is refused, so the resume call names the workflow — but only a name that leads back to the script the run executed. A workflow name can be recorded from a path in a subdirectory, from a user workflow a same-named project workflow shadows, or carried over by a retry that fell back to the run's inline copy; resuming by such a name would run a different script, or none. The runner therefore resolves the name once, as the run starts, and records it as the run's `resumeName` only when it resolves to the same file. A run without one gets no resume call; its failure notice says that only whoever started it can retry it.
The lock itself lives in one place, the Config. The Config hands it to the workflow run registry it owns, which reads it when a notification offers a resume call; the tool and the keyword reminder read the Config.
### Hosts
`workflowToolFeatures.nameOnly` in `GET /session/:id/supported-commands` reports the lock, so a host knows the model is restricted while its own `run-script` still starts runs.
## Differences from Claude Code
Claude Code's `CLAUDE_WORKFLOW_NAME_ONLY` refuses `script`, `scriptPath` and `resumeFromRunId`, limits resolution to built-in workflows, and refuses a nested `workflow({ scriptPath })` for every run. Qwen Code has no built-in workflows, and the lock serves deployments that start named runs and resume them, so it keeps every name tier (project, user, extension), allows a named resume, and leaves host-started runs unrestricted.
## Related fix
The tighten-only merge compared a workspace value against the stricter of User and SystemDefaults, while the merge lets User override SystemDefaults. With SystemDefaults stricter and User looser, a workspace value equal to SystemDefaults was dropped as "no change" although the value in force was User's. The baseline is now the value in force without the workspace: User's when User sets the key, otherwise SystemDefaults', otherwise the default. This also affects `agents.crossSessionMessaging` and `agents.crossSessionInbound`.
## Limits and risks
- The lock makes every run the model starts addressable by a name rule; it does not approve or block anything by itself. The model can still save a new workflow file and run it by name, which an approval rule scoped to specific names or script digests will ask about.
- In a locked session the `/review` workflow fan-out is unavailable, because it runs a generated script by path. The review skill already reports an unavailable tool and stops.
- The resume name is checked when a run starts. A workflow file added or removed afterwards is not reflected.
## Not in scope
- A bundled `deep-research` workflow (the other remaining part of item 12).
- An allowlist of name sources.
- Changing the lock during a session.
## Verification
- Unit tests cover each refused and accepted source, the host runs (script, script path, nested path), the schema and description shapes, the resume name when it matches and when it does not, the notices, the keyword reminder when the registry throws, the slash command, the Config's start-time decision, the tighten-only baseline for this key and for `crossSessionInbound`, and the ACP flag.
- Hand-made mutations of each new branch fail at least one test.

View file

@ -0,0 +1,78 @@
# 仅命名 workflow 锁
[English](./2026-09-17-workflow-name-only.md)
跟踪 issue:#11013 第 12 项(剩余部分)。
状态:已在 #12078 实现。
## 问题
部署方可以让模型启动扩展随包发行的 workflow(`whenToUse`,#11957),也可以按名字或路径限定审批(#11943),但没有办法阻止同一个模型自己写一段最多 1000 个 agent 的内联脚本来运行。内联脚本没有可写权限规则的名字或路径,部署方一旦为命名 workflow 放宽审批,内联脚本也会一并放行。
## 决策
### 开关
`tools.workflowNameOnly`(默认 `false`)或 `QWEN_CODE_WORKFLOW_NAME_ONLY=1` 打开锁。`Config.isWorkflowNameOnly()` 在会话启动时确定一次:Workflow 工具在启动时构建描述和参数 schema,锁若在会话中途变化,模型手里的契约就与工具实际行为不一致。
工作区可以打开锁,不能关闭。该键是"工作区只能收紧"的设置:工作区的 `true` 生效;操作者设为 `true` 时,工作区的 `false` 会被丢弃并告警。环境变量不允许由项目 `.env` 设置,仓库内容无法取消操作者导出的值。
### 锁住什么
| 来源 | 锁开时 | 理由 |
| --------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------ |
| `script` | 拒绝 | 模型在会话里写的代码没有可被规则匹配的身份。 |
| `scriptPath` | 拒绝 | 每个已保存 workflow 都能按名字到达;路径提供不了名字之外的东西,generated-scripts 根目录是运行期目录。 |
| `name`,以及 `name` 加 `resumeFromRunId` | 允许 | 可被 `Workflow(name:…[,sha256:…])` 规则匹配。 |
| 模型启动的脚本里的 `workflow({ scriptPath })` | 拒绝 | 同一条规则的下一层。`workflow('<name>')` 照常。 |
来源的判定与参数校验一致:`name` 旁边为空的 `script` 或 `scriptPath` 不会让一个按名运行的调用被拒。
### 锁约束模型,不约束宿主
拒绝放在工具的 `build` 里,模型和客户端的每次调用都经过这里,包括交互斜杠命令的 `schedule_tool`。宿主经 ACP 发起的 `run-saved`、`run-script`、retry、rerun 走 `buildSessionOwnedBackground`,与 `build` 共用参数校验但不经过 `build`,不受限制。嵌套拒绝也按同一条线划分:只有工具为模型或客户端构建的调用才会要求 runner 拒绝,宿主的脚本仍可按路径嵌套。
### 告诉模型什么
锁开的会话直接把规则告诉模型,而不是让它撞错才知道:
- 参数 schema 去掉 `script` 与 `scriptPath`。`name` 不设为必填,因为宿主的运行复用同一份 schema 校验,并从脚本启动。
- 描述用 "Named workflows only" 一段替换 authoring 指针,共享的决策与运行时文字去掉引导模型使用 `scriptPath` 或编辑已落盘脚本的句子。有测试保证这类说法不会在该段之外残留。
- `workflow` 关键词提醒引导 `{ name, args }`,不再引导写脚本。锁状态在该路径里任何可能失败的步骤之前读取。
- 交互 `/<name>` 命令按名调度。
### 按名恢复
锁关时,恢复调用仍给出脚本路径,已有的 `Workflow(scriptPath:…)` 授权继续匹配。锁开时路径被拒,恢复调用改为给出 workflow 名,但只给能回到本次运行所执行脚本的名字。workflow 名可能来自子目录里的文件路径、来自被同名项目 workflow 遮蔽的用户 workflow,或是 retry 回退到内联副本时沿用的旧名;按这样的名字恢复会运行另一个脚本,或者找不到脚本。因此 runner 在运行启动时解析一次该名字,只有解析到同一个文件时才记为该运行的 `resumeName`。没有 `resumeName` 的运行不提供恢复调用,失败通知说明只能由启动方重试。
锁本身只存在于一处:Config。Config 把它交给自己持有的 workflow 运行 registry,registry 在通知里给出恢复调用时读取;工具和关键词提醒读 Config。
### 宿主
`GET /session/:id/supported-commands` 的 `workflowToolFeatures.nameOnly` 反映锁状态,宿主据此知道模型受限,而自己的 `run-script` 仍能启动运行。
## 与 Claude Code 的差异
Claude Code 的 `CLAUDE_WORKFLOW_NAME_ONLY` 拒绝 `script`、`scriptPath` 和 `resumeFromRunId`,只解析内置 workflow,并对所有运行拒绝嵌套的 `workflow({ scriptPath })`。Qwen Code 没有内置 workflow,而这个锁面向的是启动命名运行并恢复它们的部署,所以保留全部名字层级(项目、用户、扩展),允许按名恢复,并且不限制宿主发起的运行。
## 相关修复
"工作区只能收紧"的合并逻辑把工作区的值与 User、SystemDefaults 中更严的那个比较,而合并时 User 覆盖 SystemDefaults。当 SystemDefaults 更严、User 更松时,与 SystemDefaults 相同的工作区值会被当成"没有变化"丢弃,尽管实际生效的是 User 的值。现在的基线是不含工作区时实际生效的值:User 设置了就用 User 的,否则用 SystemDefaults 的,否则用默认值。这一修复同样影响 `agents.crossSessionMessaging` 与 `agents.crossSessionInbound`。
## 局限与风险
- 锁保证模型发起的每次运行都可被按名规则寻址,它本身不批准也不阻止任何东西。模型仍可保存一个新的 workflow 文件再按名运行,按具体名字或脚本摘要限定的审批规则会对它发起询问。
- 锁开的会话里 `/review` 的 workflow 扇出不可用,因为它按路径运行生成的脚本。review skill 已规定工具不可用时报告并停止。
- 恢复名在运行启动时检查,之后新增或删除 workflow 文件不会反映出来。
## 不在范围内
- 内置 `deep-research` workflow(第 12 项的另一块剩余部分)。
- 名字来源白名单。
- 会话中途切换锁。
## 验证
- 单元测试覆盖:每一种被拒与被接受的来源;宿主运行(脚本、脚本路径、嵌套路径);schema 与描述形态;恢复名匹配与不匹配两种情况;通知文案;registry 抛错时的关键词提醒;斜杠命令;Config 在启动时确定锁;该键与 `crossSessionInbound` 的收紧基线;ACP 能力位。
- 对每个新分支的手工变异都至少让一个测试失败。

View file

@ -0,0 +1,162 @@
# Compression outcome for hosts that own their UI language
[English](2026-09-18-web-shell-context-compression-i18n.md) | [简体中文](2026-09-18-web-shell-context-compression-i18n.zh-CN.md)
Status: implemented (2026-09-18)
## Problem
`/compress` and `/compress-fast` report their outcome to ACP clients as English
sentences. A host that renders its own UI language therefore shows English, and
the token counts inside the sentence arrive pre-formatted with the daemon's
grouping and its `~` estimate marker, so the host can neither group them for its
own language nor explain the marker.
Two behaviors were also wrong regardless of language:
- `/compress-fast` with nothing to strip reused the pending phase for its
terminal result, so the row was dropped once the turn ended and the user never
learned that nothing needed compressing.
- Opening a session whose last action was a compression left the composer's
context ring at the pre-compression occupancy until the next model round. The
counters a session seeds from its replay are the last usage-bearing frame in
it, and a compression emits no usage frame, so they described the context the
compression had just replaced.
## Goals
- A host that owns its UI language renders the compression outcome in that
language, from counts it formats itself.
- Every outcome the commands can produce is representable, including the
terminal no-op.
- A host that renders text as-is sees the same sentences it saw before.
## Non-goals
- Changing what any other client renders. The compatibility analysis below is
part of the design, not a follow-up.
- Structuring the auto-compaction diagnostic, which the session prints as
ordinary assistant text rather than as a command outcome.
- New copy for the slow path's no-op, which still reports a compression of equal
counts. Tracked under follow-ups.
## Design
**D1 — The outcome rides `_meta` beside the sentence.** Each streamed message
keeps its English `content` for text-only hosts and carries a machine-readable
payload under a new key: a progress phase, a terminal no-op, or the result with
raw counts and an optional server-authored warning. The sentence stays the
complete user-visible text for any host that does not read the payload; the
payload is optional to everyone.
**D2 — The invocation note gets its own key.** The note that the instructions
were clipped is emitted before the compression starts, so it lands in the same
turn as the result. The daemon's UI reducer folds a turn's text frames into one
block and spreads `_meta` key by key, so a note sharing the result's key would
be overwritten by the frame that follows it. Keyed separately, a consumer can
render it as its own row beside the compression it belongs to.
**D3 — The no-op is terminal, not a reused pending phase.** A no-op payload
replaces the pending row in place the same way the result does, so the row
survives the turn and states the outcome. A pending row that is merely left
alone when the turn ends is dropped.
**D4 — Counts travel raw.** The payload carries numbers, never a formatted
string, and carries the estimate flags separately. The consumer chooses grouping
and whether to mark an estimate. The server never decides how a count looks.
**D5 — Only inert keys; no reuse of a marker other clients interpret.** The
discrete-message marker is load-bearing elsewhere: some bridges drop such frames
except for two specific sources, and some clients exclude them from the answer
buffer. Reusing it to make the note stand out would silently remove the note
from those clients. New keys are inert by construction: a client that does not
read them cannot observe them.
**D6 — The note is recorded as its own item.** The transcript record rebuilds
the same rows on replay, so the note is recorded separately from the joined
result text rather than concatenated into it. Its recorded text keeps a trailing
newline, which keeps the replayed text byte-identical to the pre-change record
when a consumer concatenates frames.
**D7 — The ring reconciles after the connection settles.** The counters a
session seeds from its replay can predate a compression already in the
transcript, and a compression emits no usage frame. The outcome therefore
reconciles the composer's counters by reading usage once per session, but only
while the session is live: a read is written back only when the session is
connected, not catching up, not loading, and the model and context window are
unchanged since the read was issued, and a refused write is never retried. The
reconcile is keyed on those same fields, so a read that raced the attach's own
model and context-window resolution is read again once they settle.
## Compatibility with other clients
The wire text of every compression frame is byte-identical to before, no frame
gains or loses the discrete-message marker, and the new keys are read only by
the producing CLI, the transcript-replay passthrough, and the web shell.
Per client:
- Channels (both the ACP bridge and the daemon bridge) collect slash-command
output as "last frame wins" and show the final one, so they show the result
sentence — as they already did, since the note and the progress line were
already overwritten by the frame after them.
- The IDE companion special-cases only the discrete marker and the
background-notification source for its own persistence, and treats everything
else as ordinary streamed text.
- The live client includes every non-discrete frame in its answer buffer, so it
keeps seeing the same sentences.
- The SDK collectors skip only discrete frames.
- The TUI's resumed-history renderer has no branch for the item shape these
records use, so the extra item is not rendered there — as with every other
command record before this change.
- Model history is unaffected: the judgement that removes a local command's
output from model history requires every recorded item to be an assistant
item, and both items still are.
## Risks and limitations
- A consumer that renders one row per frame sees the replayed note and result as
two rows. The web shell is the only such consumer today, and that split is the
intended behavior.
- The ring reconcile is keyed on the fields the write guard watches, so a
refusal caused by something else would not be retried. The observed cause was
the model and context window resolving after the session went live.
- A session that ends on a compression costs one extra context-usage read when
it is opened, and one more if the model and window resolve after the first
attempt.
## Validation
- Unit: the compression commands' payloads (including a warning and the terminal
no-op), the argument parser's tolerance of unknown and malformed payloads, the
row splitting and its fallback to plain text when a payload cannot be parsed,
the row renderer's copy, and the reconcile hook's deferral and re-read.
- End-to-end (Playwright, mocked daemon): the note and result rows from one
folded block, the result row surviving a following command, the terminal no-op
row, and the ring settling on the post-compression value.
- End-to-end against the real daemon with a probe covering both orderings of an
attach whose transcript contains a compression: the compression already in the
attach snapshot, and the compression arriving while the transcript is still
catching up. Both must end on the post-compression occupancy, and the first
read may be refused while the connection is still resolving its model.
## Acceptance criteria
- A host reading the payload renders the outcome in its own language with counts
it formats; a host that does not read it renders the sentences it rendered
before.
- A payload the consumer cannot parse falls back to the raw sentence rather than
rendering half of a block or dropping it.
- `/compress-fast` with nothing to strip leaves a visible terminal row.
- Opening a session whose last action was a compression settles the ring on the
post-compression occupancy without waiting for the next model round.
- No other client's rendering changes.
## Follow-ups
- The slow path's no-op still reports a compression of equal counts; giving it
the terminal no-op phase is the symmetric fix.
- The auto-compaction diagnostic is not structured, so it stays English in a
non-English UI.
- The estimate flag default for an omitted flag differs from the core
compaction paths; the divergence is deliberate and commented but not yet
aligned.

View file

@ -0,0 +1,118 @@
# 给自持 UI 语言的宿主渲染压缩结果
[English](2026-09-18-web-shell-context-compression-i18n.md) | [简体中文](2026-09-18-web-shell-context-compression-i18n.zh-CN.md)
状态:已实现(2026-09-18)
## 问题
`/compress` 与 `/compress-fast` 把结果作为英文句子报给 ACP 客户端。自持 UI
语言的宿主因此只能显示英文;句子里的 token 数还带着 daemon 的分组方式和
`~` 估算标记,宿主既不能按自己的语言分组,也无法解释这个标记。
另外两条与语言无关的行为本来就是错的:
- `/compress-fast` 在无可压缩内容时复用了 pending 阶段作为终态,轮次结束后这
一行被丢弃,用户永远不知道无需压缩。
- 打开一个"最后一次操作是压缩"的会话时,composer 的上下文环会停在压缩前的占
用上,直到下一轮模型输出才纠正。会话从回放里播种的计数是回放中最后一条带用量
的帧,而压缩本身不发用量帧,所以它描述的是刚被压缩替换掉的那份上下文。
## 目标
- 自持 UI 语言的宿主用该语言渲染压缩结果,数字由宿主自己格式化。
- 命令能产生的每种结果都可表达,包括终态的无需压缩。
- 原样渲染文本的宿主看到的句子与改动前一致。
## 非目标
- 不改变任何其他客户端的渲染。下面的兼容性分析是设计的一部分,不是后续项。
- 不结构化自动压缩那条诊断信息——它由会话作为普通 assistant 文本打印,而不是
作为命令结果。
- 不为慢路径的无需压缩选择新文案:它仍然报告一次计数相等的压缩。记在后续项里。
## 设计
**D1 —— 结果与句子并列,走 `_meta`。** 每条流式消息保留英文 `content` 供纯文
本宿主使用,同时在一个新键下携带机器可读的结果:进度阶段、终态的无需压缩,或
带原始计数与可选服务端附注的结果。对任何不读该键的宿主,句子仍是完整的用户可见
文本;该键对所有人都是可选的。
**D2 —— 调用提示独占一个键。** "指令被截断"的提示在压缩开始前发出,因此与结果
落在同一轮。daemon 的 UI reducer 会把一轮的文本帧折成一个块,并按 key 逐键
spread `_meta`,所以与结果共用同一个键的提示会被紧随其后的帧覆盖。单独占键后,
消费方可以把它渲染成压缩所属结果之前的一行。
**D3 —— 无需压缩是终态,而不是复用 pending 阶段。** 无需压缩的结果与压缩结果
一样就地替换 pending 那一行,因此该行能活过轮次结束并说明结果。轮次结束时只是
"不再更新"的 pending 行会被丢弃。
**D4 —— 计数以原始数值传输。** 结果携带数字而非格式化字符串,估算标记单独携带。
分组方式与是否标注估算由消费方决定,服务端不决定计数长什么样。
**D5 —— 只加惰性键,不复用其他客户端会解读的标记。** discrete message 标记在别处
是承重的:一些桥接会丢弃这类帧(除两个特定来源外),一些客户端会把它们排除在答案
缓冲之外。为了突出提示而复用它,会让提示在这些客户端里静默消失。新键在构造上就是
惰性的:不读它的客户端观察不到它。
**D6 —— 提示作为独立记录项落盘。** transcript 记录要在回放时重建同样的行,因此
提示与拼接后的结果文本分开记录,而不是拼进同一条。记录里提示文本保留尾部换行,
这样消费方拼接帧时,回放出的文本与改动前逐字节相同。
**D7 —— 环在连接稳定后再调和。** 会话从回放播种的计数可能早于记录里已有的压缩,
而压缩不发用量帧。因此结果会按会话读取一次用量来调和 composer 的计数,但只在会话
处于 live 时:只有会话已连接、不在追赶、不在加载中,且模型与上下文窗口自读取发出
以来未变,结果才写得回去,被拒的写入不会重试。调和正以这些字段作为 key,所以撞上
attach 自身解析模型与窗口的那次读取,会在它们落定后重读一次。
## 与其他客户端的兼容性
每个压缩帧的线上文本与改动前逐字节相同,没有任何帧新增或去掉 discrete message
标记,新键的读取方只有产出侧的 CLI、回放透传与 web shell。逐客户端:
- 渠道(ACP 桥与 daemon 桥两套一致)把斜杠命令输出按"后到覆盖先到"收集,显示最后
一帧,因此显示的是结果句——本来也是如此,因为提示句与进度句早已被其后的帧覆盖。
- IDE companion 只对 discrete 标记与 background-notification 来源做单独持久化,其
余一律当作普通流式文本。
- live 客户端把每个非 discrete 帧都算作答案缓冲的一部分,因此它看到的句子不变。
- SDK 收集器只跳过 discrete 帧。
- TUI 的恢复历史渲染对这些记录项的形状没有渲染分支,因此多出的那一项不会被渲染——
改动前所有其他命令的记录项同样如此。
- 模型历史不受影响:把本地命令输出从模型历史里摘掉的判据要求记录项全部是 assistant
项,两项仍然都是。
## 风险与限制
- 一帧一行的消费方会把回放出的提示与结果看成两行。今天唯一这样的消费方就是
web shell,而这个拆分正是目标行为。
- 环的调和以写入守卫所看的字段为 key,因此由其他原因造成的拒绝不会重试。已观察到
的原因只是模型与上下文窗口在会话转 live 之后才落定。
- 以压缩结尾的会话在被打开时会多一次上下文用量读取;若模型与窗口在第一次尝试之后
才落定,则再多一次。
## 验证
- 单测:压缩命令产出的键值(含附注与终态无需压缩)、解析器对未知与破损结果的容错、
行拆分及其在结果无法解析时退回原始文本、行渲染的文案、调和 hook 的延后与重读。
- 端到端(Playwright,模拟 daemon):同一个折成的块产出提示行与结果行、结果行在随后
的命令之后仍在、终态无需压缩行、以及环落到压缩后的数值。
- 端到端真实 daemon 探针,覆盖"记录里已有压缩"的 attach 两种时序:压缩已在 attach
快照里,以及压缩在 transcript 仍在追赶时到达。两者都必须落在压缩后的占用上,且
连接仍在解析模型时第一次读取可能被拒。
## 验收标准
- 读该键的宿主用自身语言渲染结果、自行格式化计数;不读该键的宿主渲染与改动前相同的
句子。
- 消费方读不懂的结果退回原始句子,而不是渲染半块内容或直接丢弃。
- `/compress-fast` 在无可压缩内容时留下一行可见的终态结果。
- 打开"最后一次操作是压缩"的会话后,环在不需要等到下一轮模型输出时即落到压缩后的
占用。
- 其他任何客户端的渲染都不变。
## 后续项
- 慢路径的无需压缩仍报告一次计数相等的压缩;给它终态无需压缩阶段是对称的修法。
- 自动压缩的诊断没有结构化,因此非英文 UI 下仍是英文。
- 省略估算标记时的默认值与 core 压缩路径不同;这处分歧是有意为之并已注释,但尚未拉
齐。

View file

@ -0,0 +1,78 @@
# Web Shell commit history: lane graph and search
[English](2026-09-18-web-shell-git-history-graph.md) | [简体中文](2026-09-18-web-shell-git-history-graph.zh-CN.md)
Status: implemented. Part of [#11941](https://github.com/QwenLM/qwen-code/issues/11941); the worktree manager from the same issue is a separate design.
## Problem
The Web Shell history tab lists the commits reachable from `HEAD` as a flat list. Merge commits carry a marker but the branch structure is invisible, commits on other branches are unreachable without a checkout, and there is no way to find a commit by message, author, or hash beyond scrolling.
## Current state
- `GET /workspaces/:workspace/git/log` accepts `limit`, `skip`, and a single `range`; `fetchGitLog` in `packages/core/src/utils/gitDiff.ts` rejects a `range` that starts with `-` or `..`, so multi-ref flags cannot be smuggled through it.
- The response already carries `parents` and `refs` for each entry.
- `GitLogContent` in `packages/web-shell/client/components/dialogs/GitLogDialog.tsx` renders the list and is embedded as the `log` tab of `GitDialog`.
## Goals
- Show the commit history as a lane graph: one column per concurrent line of development, curves where branches fork and merge.
- Let the user widen the walk from `HEAD` to every local branch, remote branch, and tag.
- Filter the history by message, author, or hash from the same tab.
- Open the history from the branch chip, next to the existing Changes and Commit actions.
## Non-goals
- No rendering of commits that the daemon walk does not return (no client-side graph reconstruction beyond the loaded pages).
- No new daemon route; the existing log route grows two optional query parameters.
- No changes to the commit detail endpoint or the diff tab.
## Design
### Daemon and core
`fetchGitLog` takes two new options:
- `all`: the walk covers `HEAD --branches --tags --remotes` instead of `HEAD`. Stash, notes, and replace refs stay excluded.
- `search`: a case-insensitive fixed-string filter. git ANDs `--grep` with `--author`, so message and author matches are two walks whose union is paged; a query of four to forty hex characters additionally resolves as a commit hash prefix through `git rev-parse --verify <query>^{commit}`. The union is ordered by author date, so a page boundary can shift by one entry when author and commit dates disagree; the client de-duplicates by sha.
Every walk now passes `--date-order`, so a parent never precedes one of its children and the lane layout needs nothing beyond `parents`. The route reads `all=1` and `search=<text>` (trimmed, capped at 200 characters). The SDK exposes them as a trailing `options` argument on `workspaceGitLog` in both the bound-workspace and workspace-qualified clients.
### Lane layout
`layoutCommitGraph` in `packages/web-shell/client/utils/commitGraph.ts` is a pure function over `{ sha, parents }[]` in walk order. Each lane waits for one commit:
- A commit takes the lowest lane waiting for it, or a fresh lane when none does. Every other lane waiting for the same commit merges into that node.
- Its first parent continues the lane, unless another lane already waits for that parent, in which case the line joins that lane instead of running in parallel.
- Each further parent takes the lane already waiting for it or opens a new one.
The layout is recomputed over the whole accumulated list after every page, so pagination never breaks a lane. Search results are not contiguous history, so the graph is hidden while a query is active.
### Rendering
Each row draws its own SVG stretched to the row height (`preserveAspectRatio="none"`): pass-through lanes as vertical lines, incoming and outgoing lane changes as S-curves, and the node as a positioned dot (hollow for merges). An expanded row draws the still-open lanes straight through its detail block so lines never break. Lane colours cycle through a fixed eight-colour palette; lanes beyond twenty share the last column so a busy all-branches view cannot crowd the subject out.
### Toolbar and entry points
The history tab gains a toolbar with a search field (debounced 300 ms) and an `All branches` toggle. Both re-fetch from offset zero. `BranchPickerPopover` gains an `onOpenLog` action rendered as `History` next to `View Changes`; `ChatEditor` and `EnvironmentPanel` thread it from `App`, which opens `GitDialog` on the `log` view with the session's worktree cwd when there is one.
## Constraints
- `range`, `all`, and `search` compose: `all` wins over `range`; `search` filters whatever walk the other two select.
- A hash-prefix hit is returned even when the commit is not reachable from the selected walk; the user asked for that commit explicitly.
- The graph column has a hard cap of twenty lanes. Repositories with hundreds of unmerged branch tips render the overflow in the last lane rather than widening the dialog.
## Validation
- `packages/core/src/utils/gitDiff.test.ts`: `all` walks a side branch and tag, an unsafe `range` is ignored, search unions message, author, and hash with fixed-string matching, and search pages with `hasMore`.
- `packages/cli/src/serve/routes/workspace-git-log.test.ts`: the route forwards `all` and `search`.
- `packages/web-shell/client/utils/commitGraph.test.ts`: linear history, merge lanes, lane reuse, and fresh lanes for unseen parents.
- `packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx`: the toggle and search re-fetch with the right options, matches hide the graph, and a merge lays out in two columns with lanes continuing through an expanded row.
- `packages/web-shell/client/components/BranchPickerPopover.test.tsx`: the `History` entry calls back and closes.
## Acceptance criteria
- The history tab shows a node per commit and curves at every fork and merge, on the current branch by default.
- `All branches` lists commits from every branch and tag with their ref chips.
- Typing in the search field narrows the list to matching message, author, or hash and clears back to the full history when emptied.
- The branch chip offers `History` and opens the tab.

View file

@ -0,0 +1,78 @@
# Web Shell 提交历史:泳道图与搜索
[English](2026-09-18-web-shell-git-history-graph.md) | [简体中文](2026-09-18-web-shell-git-history-graph.zh-CN.md)
状态:已实现。属于 [#11941](https://github.com/QwenLM/qwen-code/issues/11941);同一 issue 中的 worktree 管理器是独立设计。
## 问题
Web Shell 的历史标签页把 `HEAD` 可达的提交列成一条扁平列表。合并提交有标记,但分支结构不可见;其它分支上的提交不切换检出就看不到;除了滚动之外,没有办法按提交信息、作者或哈希找到某个提交。
## 现状
- `GET /workspaces/:workspace/git/log` 接受 `limit`、`skip` 和单个 `range`;`packages/core/src/utils/gitDiff.ts` 中的 `fetchGitLog` 拒绝以 `-` 或 `..` 开头的 `range`,因此多 ref 参数无法借道传入。
- 响应已为每条记录携带 `parents` 与 `refs`。
- `packages/web-shell/client/components/dialogs/GitLogDialog.tsx` 中的 `GitLogContent` 渲染列表,并作为 `GitDialog` 的 `log` 标签页嵌入。
## 目标
- 把提交历史画成泳道图:每条并行的开发线一列,分叉与合并处用曲线连接。
- 允许用户把遍历范围从 `HEAD` 扩大到所有本地分支、远程分支和标签。
- 在同一标签页内按提交信息、作者或哈希过滤历史。
- 在分支 chip 上、紧邻现有的「查看变更」和「提交」动作旁打开历史。
## 非目标
- 不渲染 daemon 遍历未返回的提交(客户端不会在已加载页面之外重建图)。
- 不新增 daemon 路由;现有 log 路由只增加两个可选查询参数。
- 不改动提交详情接口和 diff 标签页。
## 设计
### Daemon 与 core
`fetchGitLog` 新增两个选项:
- `all`:遍历 `HEAD --branches --tags --remotes` 而非仅 `HEAD`。stash、notes、replace ref 仍被排除。
- `search`:大小写不敏感的固定字符串过滤。git 对 `--grep` 与 `--author` 取 AND,因此信息匹配与作者匹配是两次遍历,取并集后分页;4 到 40 位十六进制查询还会经 `git rev-parse --verify <query>^{commit}` 按提交哈希前缀解析。并集按作者日期排序,作者日期与提交日期不一致时页边界可能偏移一条;客户端按 sha 去重。
所有遍历现在都带 `--date-order`,父提交绝不早于其任何子提交出现,泳道布局只需要 `parents`。路由读取 `all=1` 与 `search=<text>`(去空白、上限 200 字符)。SDK 在绑定工作区与限定工作区两个客户端的 `workspaceGitLog` 上以尾部 `options` 参数暴露它们。
### 泳道布局
`packages/web-shell/client/utils/commitGraph.ts` 中的 `layoutCommitGraph` 是对遍历顺序的 `{ sha, parents }[]` 的纯函数。每条泳道等待一个提交:
- 提交占用等待它的最低泳道;没有则开一条新泳道。其它等待同一提交的泳道都汇入该节点。
- 第一父提交延续该泳道;若已有别的泳道在等待这个父提交,则线条并入那条泳道而不是并行下去。
- 其余每个父提交占用已在等待它的泳道,或新开一条。
每次翻页后都对累积的整份列表重新布局,分页不会打断泳道。搜索结果不是连续历史,查询激活期间隐藏图。
### 渲染
每一行绘制自己的 SVG 并拉伸到行高(`preserveAspectRatio="none"`):穿行泳道画成竖线,进入和离开的换道画成 S 形曲线,节点是定位的圆点(合并提交为空心)。展开的行在其详情块中把仍然打开的泳道直线画穿,线条不会断。泳道颜色循环使用固定的 8 色调色板;第 20 条之后的泳道共用最后一列,繁忙的全部分支视图不会把标题挤出去。
### 工具栏与入口
历史标签页增加一条工具栏:搜索框(300 ms 防抖)和「全部分支」开关。两者都从偏移 0 重新拉取。`BranchPickerPopover` 增加 `onOpenLog` 动作,渲染为「提交历史」,位于「查看变更」旁;`ChatEditor` 与 `EnvironmentPanel` 从 `App` 透传,`App` 以 `log` 视图打开 `GitDialog`,有会话 worktree 时带上其 cwd。
## 约束
- `range`、`all`、`search` 可组合:`all` 优先于 `range`;`search` 过滤前两者选定的遍历。
- 哈希前缀命中即使不在所选遍历可达范围内也会返回;用户明确要的就是那个提交。
- 图列硬上限 20 条泳道。有数百个未合并分支尖端的仓库会把溢出画在最后一条泳道里,而不是撑宽对话框。
## 验证
- `packages/core/src/utils/gitDiff.test.ts`:`all` 遍历到侧分支和标签,不安全的 `range` 被忽略,搜索对信息、作者、哈希取并集且按固定字符串匹配,搜索分页带 `hasMore`。
- `packages/cli/src/serve/routes/workspace-git-log.test.ts`:路由透传 `all` 与 `search`。
- `packages/web-shell/client/utils/commitGraph.test.ts`:线性历史、合并泳道、泳道复用、未见父提交的新泳道。
- `packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx`:开关与搜索以正确选项重新拉取,匹配结果隐藏图,合并布局为两列且泳道穿过展开行。
- `packages/web-shell/client/components/BranchPickerPopover.test.tsx`:「提交历史」条目回调并关闭。
## 验收标准
- 历史标签页默认在当前分支上,每个提交一个节点,每处分叉与合并有曲线。
- 「全部分支」列出所有分支和标签上的提交并带 ref 标签。
- 在搜索框输入后列表收窄为信息、作者或哈希匹配的提交,清空后恢复完整历史。
- 分支 chip 提供「提交历史」并打开该标签页。

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,77 @@
# Background notification execution in Web Shell
[English](background-notification-turns.md) | [简体中文](background-notification-turns.zh-CN.md)
## Problem
Background results enter a Session queue, but their automatic execution never enters the bridge prompt lifecycle. Notifications can inherit a previous RPC prompt ID before its terminal event, later output loses its outer prompt ID, and live-state reports idle during real work. User steering can therefore be promoted into an interrupting prompt. The current user-input and stop paths also discard queued results.
## Behavior
A background subagent retains its originating execution ID and tool call ID. Its result for the currently executing turn is consumed at a safe model boundary from the same notification queue; it cannot subsequently launch a duplicate automatic turn. Results from earlier turns wait while another execution is active. Completion is visible independently from result consumption. When idle, each result starts a separate automatic execution using the shared session history.
Sending user input during an automatic execution uses the existing mid-turn queue. Input arriving at completion is consumed before the final boundary or promoted through the existing user-prompt path. Explicit stop cancels the current execution and suspends notification draining until a new user prompt; it retains pending results.
## Implementation
| Layer | Changes |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Core task registry | Capture originating prompt context at task registration and include it in terminal callback metadata. |
| ACP Session | Consume same-execution results at safe boundaries; preserve queue ownership; retain results on cancellation; emit lifecycle identity on every automatic update; use acknowledged start admission to order the previous RPC terminal before automatic output. |
| ACP bridge | Track an optional background execution descriptor (turnId, taskId, toolUseId, sourceTurnId, label, startedAt). Handle start admission/end notifications, correlate output/permissions, settle leftover mid-turn input, and include activity in snapshots and relevant operation guards. |
| SDK | Carry background execution metadata in live/load snapshots and lifecycle events, including replay. |
| Web Shell | Reuse existing task reconciliation and mid-turn input; append a lightweight result marker at its chronological position with source and existing task-panel links; retain the latest main-agent reply when folded, and show running state through silent tool gaps and refresh. |
The Session owns notification consumption; the bridge owns transport execution visibility. Start admission is acknowledged only after preceding prompt terminal publication, without waiting for a queued successor prompt (which could deadlock with child history serialization). A stopped or superseded start cannot resurrect activity. End events clear only their matching execution ID. Existing Goal semantics remain compatible.
## Files and scope
Production changes are expected in core background task metadata, ACP Session and its emitters/recording, bridge client/types/runtime, SDK daemon types/normalization, and Web Shell session state/message adapters/list rendering. Tests stay collocated. No new dependencies, generalized scheduler, or new public daemon routes are planned. Existing vendor extension methods may gain a background source.
## Verification
Focused tests cover start/terminal ordering, stale end events, same-turn result consumption, no duplicate automatic execution, foreign-turn queueing, steering at tool and final boundaries, stop retention, live/load equality, permission ownership, and replay grouping. A deterministic local mock model/daemon scenario validates the actual transport and browser projection without model variability. Baseline uses global qwen, verification uses the local bundle. Build, typecheck, focused tests, full-diff self-audit, and independent review are required.
## Decisions
User messages and model execution IDs are distinct: mid-turn guidance is a real user message in the current execution. Background task activity is distinct from parent model execution; queued or still-running tasks do not alone set hasActivePrompt. Shell, monitor, and workflow notifications share the automatic lifecycle; same-execution injection is limited to subagents with origin metadata. Missing legacy origin metadata falls back to queued automatic delivery, not guessed same-turn injection. Reliable bridge restart recovery of a surviving child must use the existing load/runtime snapshot path; never infer running state solely from historical start rows.
## Conversation presentation
Automatic execution IDs remain transport lifecycle identifiers, not additional user turns. Each background result has one lightweight marker with Source and Details actions. Source locates the originating tool; Details opens the existing right-side task view. Raw subagent output stays in that view. Duplicate completion/consumption notices are suppressed, and the originating tool reflects completion while awaiting processing.
Within each user turn, folding retains the last complete main-agent reply, including replies produced during automatic execution. Earlier main replies, thoughts, and tools collapse into the existing process section. Result markers remain visible in chronological order and do not create turn navigation entries or separate turn statistics. A subsequent user message still starts a new visible turn. Missing background metadata preserves legacy rendering.
## Background task activity
Live-state also carries `hasRunningBackgroundTasks`, independently of `hasActivePrompt` and `backgroundTurn`. At least one running background task makes the field true; no tasks still executing makes it false (paused tasks do not execute; starting/pausing workflows still count). An idle parent with running children remains available for user input while the sidebar displays background activity. Missing fields from legacy daemons remain compatible. This state must come from live task ownership, not historical transcript inference.
The sidebar retains its prompt spinner at highest priority. When the parent is idle but hasRunningBackgroundTasks is true, the leading status slot shows an amber pulsing dot and a background-task tooltip; false or absent clears the marker. Hovering the background dot keeps action buttons hidden; hovering elsewhere retains the existing actions.
Live-state requests retain their local monotonic start time through the catalog and provider. Background start/end events and load snapshots record the same local clock. A response requested before the latest background observation cannot replace its state or invoke idle settlement; a later idle response still settles a background execution whose terminal event was lost, even if no active poll was observed. These timestamps stay in Web Shell and require no daemon protocol change.
## Recovery after notification transport failures
The existing active-work heartbeat repeats the last finished background execution ID. The bridge settles only a matching execution, so a lost end notification recovers without user input and stale reports cannot stop a newer execution. A successful subsequent RPC also proves that its preceding automatic execution drained; dispatch alone does not.
Admission failures retain the result and retry at 1, 2, and 4 seconds, then visibly ask for user input rather than spin indefinitely. Retries reuse the execution ID and start admission is idempotent. Explicit stop cancels retry timers; a new user prompt re-arms retained results.
SDK start markers normalize as status, not assistant text. Malformed background descriptors are ignored on load and live-state. Folding treats background markers as execution boundaries so tool-only or cancelled continuations cannot hide a previously complete main-agent reply.
## Attribution and interleaved results
Explicit background provenance wins during RPC handoff. Untagged permissions, text, mode, terminal-sequence and artifact events from an active user RPC retain that RPC's identity and originator even when an old background descriptor awaits reconciliation. Status markers split transcript text blocks, so later text cannot move ahead of the marker. SDK replay derives a missing prompt ID from validated background provenance.
A background marker captures its task result when the execution is first observed. Later output from that execution must not consume or relabel a newer completion with the same task ID. Explicit consumption notifications can still acknowledge the newer result. Regression coverage includes cancelled admission waits, foreign background admission, and task-list detail recovery when complete transcript history lacks the originating tool.
Review fixes preserve all main-answer segments separated only by passive task-completion markers when folding. Tool work and automatic-execution markers remain answer boundaries. A pending prompt cancelled before execution does not release the stop-induced notification pause. Mid-turn drains carry the actual consuming RPC/background execution ID; the bridge validates it before removing queued input and preserves it across attachment I/O. Legacy drains without the field retain their fallback. A stale background descriptor cannot discard a newer local prompt terminal, including a terminal received before HTTP acceptance; the old background terminal cannot settle that unbound local prompt.
Same-turn consumption includes any pending queue-overflow summary before the retained results, records it, and preserves the combined input on cancellation. A display failure does not discard the model input. Web Shell only deduplicates notifications with a task ID, so independent queue-loss diagnostics remain visible. Background admission checks the worktree-reset barrier before and after waiting for the preceding terminal; a surviving old session cannot start automatic work after ownership transfer.
During an RPC/background handoff, cancellation and the shared steering queue belong to the active RPC. A background-only execution can still drain steering and claim Todo Stop Guard continuation under its admitted ID. Non-daemon ACP hosts report unsupported admission methods with JSON-RPC method-not-found, preserving the existing legacy fallback. Background status prose and all background-stamped chunks are excluded from foreground answer collectors; automatic terminal events do not finish another consumer's main prompt. Web Shell buffers early terminals until prompt acceptance identifies their owner, and ignores stale terminals only for execution settlement, retaining their transcript errors.
Qwen Live queues a handoff as a foreground prompt when only an automatic execution is active, because its job receipts require a foreground terminal. It seeds this distinction from the create/attach snapshot as well as live events. Web Shell continues to accept mid-turn input during automatic execution.
When a same-session load snapshot clears a background execution, Web Shell retains its finished ID so replayed start events cannot revive it.
A background terminal wakes `wait_threads` when the current session summary reports no active prompt; it does not wake the waiter while another prompt remains active. Marker completion falls back to the execution descriptor if its task record is missing. Live-state freshness checks use the actual session routing owner in both workspace and standalone panes.

View file

@ -0,0 +1,77 @@
# Web Shell 中的后台通知执行
[English](background-notification-turns.md) | [简体中文](background-notification-turns.zh-CN.md)
## 问题
后台结果会进入 Session 队列,但处理结果的自动执行没有进入 bridge 的 prompt 生命周期。通知可能在前一个 RPC 的终态事件发布前继承其 prompt ID,后续输出又丢失外层 prompt ID,live-state 则在实际执行期间报告空闲。因此,用户插入的消息可能被提升为中断当前执行的新 prompt。原有用户输入和停止路径还会丢弃排队中的结果。
## 行为
后台子智能体保留发起它的执行 ID 和工具调用 ID。如果结果属于当前正在执行的 turn,就在模型的安全边界从同一个通知队列中消费,此后不会再触发重复的自动 turn。其他执行活跃时,来自更早 turn 的结果继续等待。任务完成的展示独立于结果消费。空闲时,每条结果启动一次使用共享会话历史的独立自动执行。
自动执行期间发送的用户输入使用现有 mid-turn 队列。在执行结束边界到达的输入,会在最终边界前消费,或通过现有用户 prompt 路径提升为后续执行。显式停止取消当前执行,并暂停通知消费,直到新的用户 prompt 开始执行;待处理结果会保留。
## 实现
| 层 | 改动 |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Core 任务注册表 | 注册任务时捕获来源 prompt 上下文,并在终态回调元数据中携带。 |
| ACP Session | 在安全边界消费属于同一执行的结果;维护队列归属;取消时保留结果;为自动执行的每条更新携带生命周期标识;通过需要确认的启动准入,确保前一个 RPC 的终态先于自动输出发布。 |
| ACP bridge | 跟踪可选的后台执行描述符(turnId、taskId、toolUseId、sourceTurnId、label、startedAt)。处理启动准入和结束通知,关联输出及权限请求,处理剩余 mid-turn 输入,并将执行状态纳入快照及相关操作保护。 |
| SDK | 在 live/load 快照和生命周期事件中携带后台执行元数据,包括回放。 |
| Web Shell | 复用现有任务状态同步和 mid-turn 输入;按时间顺序追加轻量结果标记,提供来源和现有任务面板入口;折叠时保留最新主智能体回复,并在工具没有输出及页面刷新期间保持执行状态。 |
Session 负责通知消费,bridge 负责传输层执行状态的可见性。只有前序 prompt 的终态发布后,才确认启动准入;不等待排队中的后续 prompt,以免与子进程的历史串行化形成死锁。已被停止或取代的启动请求不能重新激活执行状态。结束事件只清理匹配的执行 ID。现有 Goal 语义保持兼容。
## 文件与范围
生产改动涉及 core 后台任务元数据、ACP Session 及其事件发送和记录、bridge 客户端/类型/运行时、SDK daemon 类型与规范化,以及 Web Shell 会话状态、消息适配器和列表渲染。测试与对应源码放在一起。不计划新增依赖、通用调度器或公开 daemon 路由。现有厂商扩展方法可以增加后台来源。
## 验证
定向测试覆盖启动与终态顺序、过期结束事件、同 turn 结果消费、不重复自动执行、其他 turn 的结果排队、工具和最终边界的用户输入、停止时保留结果、live/load 一致性、权限归属及回放分组。使用确定性的本地模拟模型/daemon 场景验证实际传输和浏览器投影,排除模型输出变化。基线使用全局 qwen,修后验证使用本地 bundle。需要完成构建、类型检查、定向测试、完整 diff 自审和独立 review。
## 决策
用户消息与模型执行 ID 是不同概念:mid-turn 指导是当前执行中的真实用户消息。后台任务活动与主模型执行也是独立状态;仅有排队或仍在运行的任务不会使 hasActivePrompt 变为 true。Shell、monitor 和 workflow 通知共用自动执行生命周期;同执行注入仅适用于带来源元数据的子智能体。旧数据缺少来源元数据时,回退到排队自动投递,不猜测其是否属于当前 turn。bridge 重启后若子进程仍存活,可靠恢复必须使用现有 load/运行时快照路径,不能仅凭历史启动记录推断正在运行。
## 对话展示
自动执行 ID 仍是传输层生命周期标识,不产生额外用户轮次。每个后台结果有一个轻量标记,提供“来源”和“查看详情”操作。来源定位发起任务的工具,详情打开现有右侧任务视图。子智能体原始输出保留在该视图中。完成和消费的重复通知会被去重,原工具在等待结果处理时显示完成状态。
每个用户轮次折叠时,保留最后一段完整的主智能体回复,包括自动执行产生的回复。较早的主回复、思考和工具折叠进现有过程区。结果标记按时间顺序保持可见,不产生轮次导航项或独立轮次统计。后续用户消息仍开始新的可见轮次。缺少后台元数据时沿用旧展示方式。
## 后台任务活动
Live-state 还携带 `hasRunningBackgroundTasks`,与 `hasActivePrompt` 和 `backgroundTurn` 独立。至少有一个后台任务正在运行时为 true,没有任务仍在执行时为 false(暂停任务不执行;正在启动或暂停过程中的 workflow 仍计入)。主智能体空闲、子任务运行时,用户仍可输入,侧栏展示后台活动。兼容旧 daemon 缺少这些字段的情况。状态必须来自实时任务归属,不能从历史 transcript 推断。
侧栏优先显示 prompt 的 loading 图标。主智能体空闲且 hasRunningBackgroundTasks 为 true 时,前置状态位显示琥珀色呼吸灯及后台任务提示;false 或字段缺失时清除该标记。鼠标悬浮在后台灯上时隐藏操作按钮,悬浮在其他位置时保留原有操作。
Live-state 请求通过 catalog 和 provider 保留本地单调时钟的请求开始时间。后台 start/end 事件和 load 快照使用相同本地时钟记录观察时间。早于最新后台状态观察发起的响应,不能替换该状态或触发空闲收尾;后续空闲响应仍可收尾终态事件丢失的后台执行,即使此前没有轮询到活跃状态。这些时间戳仅保留在 Web Shell,无需修改 daemon 协议。
## 通知传输失败后的恢复
现有 active-work 心跳重复报告最后结束的后台执行 ID。bridge 只收尾匹配的执行,因此结束通知丢失后无需用户输入即可恢复,过期报告也不能停止较新的执行。后续成功的 RPC 同样能证明其前序自动执行已经结束;仅派发 RPC 不能作为证明。
准入失败时保留结果,并在 1、2、4 秒后重试;持续失败后明确提示用户发送消息,不无限循环。重试复用执行 ID,启动准入具备幂等性。显式停止取消重试定时器;新的用户 prompt 重新启用保留结果的处理。
SDK 将启动标记规范化为状态,而非 assistant 正文。Load 和 live-state 忽略格式错误的后台描述符。折叠逻辑将后台标记视为执行边界,因此只有工具输出或被取消的续跑不会隐藏此前完整的主智能体回复。
## 归属与交错结果
RPC 交接期间优先采用显式后台来源。即使旧后台描述符仍等待同步,活跃用户 RPC 的未打标权限、文本、模式、终端序列及产物事件也保留该 RPC 的身份及发起客户端。状态标记分割 transcript 文本块,后续文本不会移到标记之前。SDK 回放根据经过验证的后台来源补齐缺少的 prompt ID。
后台标记在首次观察到执行时捕获对应任务结果。该执行的后续输出不能消费或改写同一任务 ID 的较新完成结果;显式消费通知仍可确认较新结果。回归覆盖准入等待被取消、其他后台执行的准入,以及完整 transcript 历史缺少发起工具时从任务列表恢复详情。
Review 修复确保折叠时保留仅被被动任务完成标记分隔的所有主回复片段。工具执行和自动执行标记仍作为回复边界。在真正开始执行前被取消的 pending prompt,不会解除停止操作造成的通知暂停。Mid-turn drain 携带实际消费它的 RPC/后台执行 ID;bridge 在移除排队输入前验证该 ID,并在附件 I/O 期间保留归属。缺少该字段的旧 drain 保留原有回退。过期后台描述符不能丢弃较新本地 prompt 的终态,包括 HTTP 准入返回前收到的终态;旧后台终态也不能收尾尚未绑定 ID 的本地 prompt。
同 turn 消费在保留结果之前加入待发送的队列溢出摘要,并进行记录;取消时保留组合后的输入。展示失败不会丢弃模型输入。Web Shell 只对带任务 ID 的通知去重,因此独立的队列丢弃诊断仍然可见。后台准入在等待前序终态之前和之后都检查 worktree 重置屏障;所有权转移后,存活的旧 session 不能启动自动执行。
在 RPC 与后台执行交接期间,取消及共享的插入消息队列归属于活跃 RPC。只有后台执行时,它仍可使用已准入的执行 ID 消费插入消息并申请 Todo Stop Guard 续跑。非 daemon ACP host 对不支持的准入方法返回 JSON-RPC method-not-found,保留现有兼容降级。前台回复收集器排除后台状态文案及所有带后台执行标记的文本块;自动执行的终态不会结束其他消费端的主 prompt。Web Shell 暂存提前到达的终态,待 prompt 准入响应确认归属后再结算;旧终态只跳过执行收尾,仍保留其转录错误。
Qwen Live 在只有自动执行活跃时,将转交任务排为前台 prompt,因为其任务回执需要对应的前台终态。它同时从 create/attach 快照和实时事件初始化这一区分。Web Shell 仍允许在自动执行期间插入消息。
同一 session 的 load 快照清空后台执行时,Web Shell 保留其已结束 ID,避免回放的 start 事件使其再次变为运行中。
后台终态到达时,如果当前 session 摘要显示已无活跃 prompt,则唤醒 `wait_threads`;其他 prompt 仍活跃时不唤醒。标记缺少任务记录时,使用执行描述符判断任务完成。无论 workspace 还是 standalone 界面,live-state 的新旧检查都采用实际 session 的路由归属。

View file

@ -0,0 +1,88 @@
# Browser Use profile ownership
[English](browser-use-profile-ownership.md) | [简体中文](browser-use-profile-ownership.zh-CN.md)
## Problem and scope
Multiple Chrome profiles can load the Qwen extension and share one Native Host
registration. Each connects independently to the same Browser Use socket.
Previously, every handshake replaced the current connection, invalidating tabs
and routing later requests to another profile. A single Browser Use session
could therefore fail repeatedly as profiles reconnected.
This change retains the [single-session boundary](browser-use.md): one active
Browser Use runtime per OS user. It fixes profile contention without adding a
profile picker or concurrent Browser Use sessions.
## Identity and selection
Each extension profile persists a random UUID in `chrome.storage.local` under
`browserUseInstanceId`. It writes the value before connecting and reuses it
after Native Host, service worker, and browser restarts. A failed storage write
prevents that connection attempt; the existing reconnect alarm retries it.
Clearing extension data or reinstalling can change the identity.
Protocol version `2` requires `extensionInstanceId` in `hello`, alongside the
shared `extensionId`. The transport rejects missing or invalid instance IDs
before selection. Extension and runtime must be updated together; version `1`
handshakes cannot safely identify profiles and are rejected. Native Host remains
an opaque relay. These identifiers distinguish compatible instances; they do
not authenticate other processes running as the same OS user.
During discovery, an incompatible Qwen extension does not prevent a compatible
profile from connecting within the normal connection timeout. If none connects,
the request fails with `EXTENSION_VERSION_MISMATCH`, whose message identifies
whether the extension or Qwen Code needs updating. Browser discovery surfaces
this error instead of reporting an empty browser list, which it reports only
for a plain `BROWSER_DISCONNECTED`. After a profile is selected, only that
disconnected profile can supply this diagnostic; unrelated profiles cannot
override it. A compatible connection or stopping the transport clears the
diagnostic.
The first valid handshake selects a profile for the current transport lifetime.
Initial selection depends on connection order, not a prediction of the user's
preferred work or personal account. Other profiles remain connected but idle.
Their messages cannot resolve active requests, emit events to the SDK, replace
the selected socket, or reset its tabs and session name.
## Disconnect and stop
A disconnect fails the selected connection's pending requests and invalidates
its tab handles using the existing lifecycle. It retains the selected instance
identity. Only that instance can reconnect; another connected profile cannot
take over while it is absent. Requests time out with `BROWSER_DISCONNECTED` if
the selected profile does not return, or with `EXTENSION_VERSION_MISMATCH` if
it returned with an incompatible protocol version. Existing pages may be
rediscovered and claimed after reconnection; old handles are not transparently
restored.
Stopping the transport closes active and idle connections and clears selection.
A new transport lifetime can select a different profile. The extension's
persistent instance identity is not cleared by stopping a Browser Use session.
## Validation and acceptance
- Reproduce two profiles exposing the same tab ID; connecting the second must
not change where the first profile's request is sent.
- Pending requests and events remain isolated from idle profiles, and stopping
an idle connection does not notify the SDK that its browser disconnected.
- Two real isolated Chrome profiles share the same socket for at least 95
seconds without invalidating the original tab or resetting its group name.
- Closing and reopening the idle profile leaves the active profile usable.
Closing the active profile never fails over; restarting it permits recovery.
- An identity survives extension reconnects and worker restarts, differs across
profiles, and is persisted before use. A storage failure must not publish a
temporary identity.
- Stopping and restarting the transport permits a new selection and closes all
old connections. The existing second-runtime `BROWSER_USE_BUSY` behavior stays
in place.
## Concurrent-session follow-up
Tracked in [#11609](https://github.com/QwenLM/qwen-code/issues/11609).
Profile identity and Browser Use session identity are separate concepts. Future
sessions may share one profile while owning different tabs. That work requires
shared connection routing, session-scoped tab ownership, events, cleanup and
reconnection. It must not reuse CDP child-target `sessionId` as the Browser Use
session identity. This fix does not implement those mechanisms.

View file

@ -0,0 +1,71 @@
# Browser Use 的 profile 归属
[English](browser-use-profile-ownership.md) | [简体中文](browser-use-profile-ownership.zh-CN.md)
## 问题与范围
多个 Chrome profile 可以安装 Qwen 扩展并共享同一份 Native Host 注册。
各 profile 独立连接同一个 Browser Use socket。此前,每次握手都会替换当前连接,
使标签页失效,并把后续请求发往另一个 profile。因此,即使只有一个 Browser Use
session,也会在多个 profile 重连时反复失败。
本次保持[单 session 边界](browser-use.md):每个 OS 用户只能运行一个活跃的
Browser Use runtime。此次只解决 profile 抢占,不增加 profile 选择界面或并发
Browser Use session。
## 身份与选择
每个扩展 profile 在 `chrome.storage.local` 的 `browserUseInstanceId` 中持久保存
一个随机 UUID。连接之前先写入,Native Host、service worker 和浏览器重启后复用。
如果写入失败,本次不连接,由现有重连 alarm 重试。清除扩展数据或重新安装可能改变身份。
协议版本 `2` 要求 `hello` 除了共享的 `extensionId`,还必须包含
`extensionInstanceId`。传输层在选择 profile 前拒绝缺失或无效的实例 ID。
扩展与 runtime 必须一起更新;版本 `1` 的握手无法可靠区分 profile,因此会被拒绝。
Native Host 继续只转发消息。这些标识用于区分兼容的实例,不能认证以同一 OS 用户
身份运行的其他进程。
发现连接时,不兼容的 Qwen 扩展不会阻止兼容 profile 在正常连接超时窗口内接入。
如果没有兼容连接,请求以 `EXTENSION_VERSION_MISMATCH` 失败,其消息会明确指出
需要更新扩展还是 Qwen Code。浏览器发现会直接报告该错误,而不是返回空的浏览器
列表;只有普通的 `BROWSER_DISCONNECTED` 才会被报告为空列表。选定 profile 后,
只有该 profile 断线后的握手可以提供这一诊断,其他 profile 不能覆盖它。
兼容连接建立或停止 transport 时会清除诊断。
第一个有效握手选定当前 transport 生命周期使用的 profile。首次选择取决于连接顺序,
不会推测用户偏好的工作或个人账号。其他 profile 保持连接并待命。
它们的消息不能完成当前请求、向 SDK 发送事件、替换选中的 socket,或重置其标签页
和 session 名称。
## 断开与停止
连接断开时,按照现有生命周期让该连接的待完成请求失败,并使标签页句柄失效,
但保留选中的实例身份。只有同一实例可以重连;原实例不在线时,其他已连接 profile
也不能接管。如果选中的 profile 未返回,请求超时后报告 `BROWSER_DISCONNECTED`;
如果它以不兼容的协议版本返回,则报告 `EXTENSION_VERSION_MISMATCH`。
重连后可以重新发现并认领已有页面;旧句柄不会透明恢复。
停止 transport 会关闭活跃连接和待命连接,并清除选择。新的 transport 生命周期
可以选择另一个 profile。停止 Browser Use session 不会清除扩展持久保存的实例身份。
## 验证与验收
- 复现两个 profile 具有相同 tab ID 的场景;第二个连接不能改变原 profile 请求的去向。
- 待完成请求与事件必须和待命 profile 隔离,关闭待命连接不能通知 SDK 当前浏览器已断开。
- 两个真实的隔离 Chrome profile 共用同一 socket 至少 95 秒,原标签页不失效,
分组名称不重置。
- 关闭并重启待命 profile 不影响活跃 profile;关闭活跃 profile 不自动切换,
重启原 profile 后可以恢复使用。
- 扩展重连和 worker 重启后身份不变,不同 profile 的身份不同,且使用前已持久保存。
存储失败时不能发布临时身份。
- 停止并重启 transport 可以重新选择,且所有旧连接均被关闭。第二个 runtime 仍报告
`BROWSER_USE_BUSY`。
## 并发 session 后续工作
由 [#11609](https://github.com/QwenLM/qwen-code/issues/11609) 跟进。
Profile 身份与 Browser Use session 身份是两个概念。未来多个 session 可以共享
一个 profile,并分别拥有不同的标签页。这需要实现共享连接路由,以及按 session
隔离的标签页归属、事件、清理和重连。不能复用 CDP 子目标的 `sessionId` 作为
Browser Use session 身份。本次不实现这些机制。

556
docs/design/browser-use.md Normal file
View file

@ -0,0 +1,556 @@
# Browser Use with Playwright Core
[English](browser-use.md) | [简体中文](browser-use.zh-CN.md)
## Goal
Browser Use gives models a structured API for controlling the user's existing
Chrome from Qwen Code.
- The Browser SDK runs as a library inside the persistent Node Kernel exposed
by the Node REPL MCP server.
- `playwright-core` provides standard browser automation semantics.
- The Qwen Chrome extension and `chrome.debugger` connect the runtime to Chrome.
- The first release supports one active Browser Use session.
## Architecture
```mermaid
flowchart TB
Agent["Qwen Code agent"] -->|"MCP"| Repl["Node REPL MCP server"]
Repl -->|"cell execution"| SDK
subgraph Kernel["Persistent Node Kernel process"]
direction TB
SDK["Browser SDK"]
SDK --> Runtime["Browser runtime<br/>validation, lifecycle, budgets"]
Runtime -->|"Playwright actions"| Playwright["playwright-core"]
Playwright --> PWTransport["QwenPlaywrightTransport"]
PWTransport --> ExtensionTransport["ChromeExtensionTransport"]
Runtime -.->|"Qwen control and screenshots"| ExtensionTransport
end
ExtensionTransport -->|"local socket"| Host["Native Host"]
Host -->|"Chrome Native Messaging"| Extension["Qwen Chrome extension"]
Extension -->|"CDP and extension APIs"| Chrome["User's existing Chrome<br/>tabs, profile, signed-in state"]
```
Standard browser actions pass through Playwright. Browser control operations
and screenshot acquisition share the same Native Messaging path but bypass
Playwright's browser-level CDP adapter.
`playwright-core@1.62.1` accepts a public custom CDP transport through
`chromium.connectOverCDP(transport)`. Qwen therefore keeps Native Messaging and
does not add a local WebSocket server.
Browser Use ships with Qwen Code as a bundled skill and its runtime resources.
No separate Browser Use runtime package or second Qwen extension is required;
the existing Qwen Chrome extension is reused. The skill's `runtime/` directory
contains the Browser SDK, Native Host, and pinned Playwright dependency. The
skill registers `runtime/node_modules` with the existing Node
REPL and imports `runtime/index.js`; the CLI does not execute browser logic.
The kernel evaluates that import in an isolated realm without Node globals, so
the runtime bundle binds `process` itself at its top through `createRequire`;
the build's load check runs in the main realm and cannot detect a missing
binding, so a kernel-backed test pins it. Source development, transpiled builds, and the published CLI use this same
layout. The generic Node REPL MCP server must be configured, and the Qwen
Chrome extension must be installed in the browser. Bundling does not connect
to Chrome at CLI startup; the SDK connects when first used.
No build step writes this runtime into the source tree. The bundle step copies
it into `dist/bundled/browser-use/runtime` for the shipped CLI, and `npm run
dev` copies the same files next to the source `SKILL.md` (a git-ignored
location) because only development mode reads bundled skills from the source
tree; the normal installation `prepare` hook builds the package those copies
come from. After changing Browser Use sources or dependencies, run
`npm run build --workspace=@qwen-code/browser-use` and start `npm run dev`
again to refresh the development copy. CLI and Core continue to run directly
from TypeScript source. If the runtime is missing, the skill's existing setup
check reports the incomplete runtime when invoked.
Browser Use is available to the model by default and is selected according to
the user's task. Users can disable it through `/skills` or `skills.disabled`,
using the same controls as Computer Use. Disabled skills are excluded
from model discovery and skill invocation. This is the existing generic skill
mechanism used by Computer Use, not a browser permission boundary: disabling
the skill does not unload instructions already in a conversation or disconnect
an existing SDK session.
Native Host registration is native-side product setup, not a Chrome-extension
operation. On macOS and Linux, the first Browser runtime initialization
checks Google Chrome, Chrome for Testing, and Chromium's standard `Default`
and `Profile N` profiles for the Qwen extension. It reads the extension's
registration in `Secure Preferences` or `Preferences` and confirms that its
manifest exists, supporting both packaged and unpacked installations. Leftover
extension directories alone do not count as an installed extension. If the
extension is not found, initialization reports how to install it without
writing Native Host files. After detection, initialization idempotently
installs the launcher and manifests for existing browser roots. A configured
`QWEN_BROWSER_USE_SOCKET_PATH` keeps using its externally managed setup.
Installing the Qwen Chrome extension opts into this automatic local setup on
first use. The launcher and Native Messaging registrations persist after
Qwen exits. The installer refuses to overwrite
foreign files: a conflicting launcher aborts initialization, while a
conflicting browser manifest is skipped and named in a warning on stderr.
Running
`node <skill-base>/runtime/scripts/native-host-setup.js uninstall` removes
files owned by Browser Use; `status` checks them and `install` explicitly
registers them. To prevent automatic registration on a later Browser Use
initialization, also uninstall the Chrome extension. Only a missing file is
treated as absent; other
read failures abort the operation without overwriting the unreadable file. The
Chrome extension only opens the registered host
through `connectNative()`.
## Responsibilities
| Component | Responsibility |
| -------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Node REPL | Generic process isolation, persistent bindings, cancellation, and output budgets. It contains no browser logic. |
| Browser SDK | Model-facing task API. Its private adapter enforces the JSON boundary without exposing transport details. |
| Browser runtime | Command validation, Qwen tab lifecycle, screenshot acquisition, output budgets, and diagnostics. |
| `playwright-core` | Locators, AI accessibility snapshots and refs, frames, navigation waits, actions, and events. |
| `QwenPlaywrightTransport` | Adapts Playwright's browser-level CDP connection to Qwen tab and child-session identifiers. |
| `ChromeExtensionTransport` | Owns the local socket and directly exchanges requests and events with the Native Host. |
| Native Host | Minimal framed relay between the local socket and Chrome Native Messaging. |
| Chrome extension | Chrome permissions, `chrome.debugger` attachment, and CDP forwarding. |
## Browser SDK
The Browser SDK is the model-facing object API. `BrowserAgent` selects a
browser backend, `Browser` provides user-tab discovery and tab management, and
each `Tab` exposes navigation, screenshots, dialogs, and three interaction
styles. The current backend controls the user's Chrome through the Qwen
extension.
The SDK and browser runtime share one internal command contract. SDK objects
translate model calls into validated commands, and the runtime executes those
commands against a Playwright `Page` or the screenshot/control adapters.
Playwright objects, CDP sessions, and transport details are not exposed through
the SDK.
Every SDK object is bound to the runtime session that created it. Closing that
runtime marks its Agent, Browser, Tab, and Locator objects stale; initializing a
new runtime never redirects older objects to the new backend.
The model-facing API structure follows the Codex Browser Use SDK. Its three
tab interaction APIs are kept separate because they represent different ways
for the model to identify a target: semantic page structure, DOM snapshot
nodes, and visual coordinates. This is a grounding distinction rather than an
implementation distinction or an API compatibility layer.
Playwright is the common automation engine behind all three interaction
styles:
| API | Grounding | Playwright implementation |
| ---------------- | ----------------------- | -------------------------------------------------------------- |
| `tab.playwright` | Semantic page structure | `Page`, `Locator`, and `FrameLocator` |
| `tab.dom_cua` | Snapshot `node_id` | AI accessibility snapshot and `aria-ref` locator |
| `tab.cua` | Viewport coordinates | Mouse and keyboard input, with CDP for auxiliary mouse buttons |
`tab.playwright` is used when an element can be described semantically.
`tab.dom_cua` is used when the model identifies an element in a DOM snapshot.
`tab.cua` is used when the target is identified visually in a screenshot.
The extension renders a transient pointer overlay for coordinate mouse input,
but that decoration is best-effort and never delays the input command itself.
Its DOM node is created on mouse input and removed when the pointer expires;
read-only inspection does not create an overlay node. The extension creates
and retains its own node instead of adopting a page element with the same ID.
Browser operations run in the background. New tabs do not replace the user's
active tab, and input actions do not bring Chrome to the foreground. Page focus
emulation keeps background rendering and input active without changing desktop
focus.
Page and locator evaluation accept functions or strings. Functions are invoked
with their documented arguments; strings return their JavaScript `eval`
completion value, including trailing semicolons, comments, and statements.
String evaluation retains the SDK's lexical `arg`, `element`, and `elements`
bindings. A function-valued string is not invoked. Use async function arguments
for `await` and parenthesize object literals in strings. Evaluation deadlines
cover the whole call, including element lookup, and report `OPERATION_TIMEOUT`.
Explicit operation timeouts must be integers from 1 to 120,000 ms; zero is
rejected with `INVALID_ARGUMENT`. Omitted timeouts retain each operation's
default. The delay-only `waitForTimeout` accepts 0 to 120,000 ms, including a
zero-delay no-op. A deadline ends the caller's wait without
terminating JavaScript already running in the page.
Evaluation accepts JSON data: finite numbers, strings, booleans, null, arrays,
and plain records, including readonly TypeScript data. The public
`JsonSerializable` type constrains arguments and results. Non-JSON arguments
are rejected before dispatch; results are checked in the page before
Playwright serializes them. The runtime transfers encoded JSON text so object
keys such as `__proto__` retain their data meaning. Nested undefined values,
non-finite numbers, Date, RegExp, functions, and cycles are rejected rather than
silently converted. An omitted top-level argument remains undefined; a
top-level undefined result becomes null, reflected in the return type. A callback
typed as void may discard an actual return value, so its return type is the
broader JsonSerializable rather than promising null.
Locator plans support at most 32 steps per array and 32 plan levels, counting
the top-level array as level one. The depth limit applies equally to `and`,
`or`, `filter.has`, and `filter.hasNot`. Over-depth plans fail validation with
`INVALID_ARGUMENT` before browser startup; normal locator composition remains
supported.
Input actions and navigation waits have separate deadlines. Locator clicks,
locator key presses, and DOM CUA clicks disable Playwright's implicit
post-action navigation wait. The action deadline covers performing input;
`expectNavigation()` registers its listener before the action and waits for
the requested navigation state with its own deadline. Successful input does
not imply that the destination has loaded. A short, bounded renderer drain
allows queued input handlers to run without waiting for a new page context.
`tab.playwright.domSnapshot()` and `tab.dom_cua.get_visible_dom()` return the
same Playwright AI accessibility snapshot, subject to the existing 20,000-character
budget. Neither filters by role or cursor style: those hints cannot reliably
distinguish clickable elements from static content. The snapshot therefore
includes static text and containers alongside controls. A ref identifies a
node, not a guarantee that clicking it performs an action. DOM CUA actions
resolve these ids through Playwright's `aria-ref` locator. A ref resolves only
while the snapshot that emitted it is current: Playwright restarts ref
numbering on every new document, so a main-frame navigation invalidates the
refs of earlier snapshots. The adapter and its
tests are pinned to the same Playwright version because the snapshot text
format is version-sensitive.
Playwright's public CDP session API supplies coordinate CUA buttons 4 (back)
and 5 (forward), which the higher-level Playwright mouse API does not expose.
Snapshot truncation, screenshot encoding and budgets, stale-session detection,
and the JSON transport envelope remain runtime implementation details rather
than model-facing options.
Viewport screenshots return JPEG bytes, a MIME type, and metadata carrying the
original image dimensions, viewport, device pixel ratio, and CSS-pixel coordinate
space so visual coordinates remain usable when a model client resizes the
preview. The skill passes the complete screenshot to `nodeRepl.emitImage()`.
Metadata travels on the image event and is returned immediately before each
retained image, independently of the ordinary text output budget. Rejected or
omitted images do not leave metadata behind. There is no metadata-specific size
cap; the existing protocol-frame and client output limits still apply.
Bundling the Node REPL MCP server with Qwen Code is deferred to a follow-up.
This protocol support first ships in `@qwen-code/node-repl-mcp` 0.1.6 and is
absent from the published 0.1.2 through 0.1.5 packages, so the Browser Use
skill pins that version instead of `latest`.
Viewport screenshots are limited by their encoded byte size rather than rejected
from viewport dimensions alone. Explicit clips and full-page captures retain a
pixel budget because their dimensions are caller-controlled or potentially
unbounded.
The device pixel ratio probed from page script is trusted only within the range a real Chrome window can report; when a capture's pixels disagree with the requested CSS region, the runtime derives the real ratio from Chrome's own output and re-captures once.
Screenshot acquisition follows the Codex Browser Use strategy independently of
Playwright's screenshot preparation. A short, bounded rendering synchronization
lets pending paint catch up before capture. Normal viewport capture requests a
fresh CDP screencast frame with a two-second frame deadline, then falls back to
`Page.captureScreenshot` with a five-second command timeout. Clips and full-page
captures use the latter directly. Frames predating the request are discarded;
captures on each tab are serialized and their event listeners and screencasts
are cleaned up. The runtime owns these events so Playwright does not acknowledge
the same frames. Images use JPEG quality 80, retain CSS-pixel coordinates, and
never require activating the tab or bringing Chrome to the foreground. An
individual screenshot timeout does not detach the browser session.
Locator `downloadMedia()` triggers a media or file-link download, while
`waitForEvent("download")` provides synchronization for downloads triggered
by other page actions. The returned download object is opaque and does not
expose the host filesystem path.
`downloadMedia()` is a Qwen adapter because Playwright has no equivalent
locator method. Qwen resolves the element through a Playwright locator, briefly
creates a page-local download link for the resolved media URL, clicks it, and
removes it immediately. Callers synchronize through Playwright's `download`
event. The media bytes are read from the page origin, so a cross-origin
resource whose server sends no CORS headers cannot be downloaded this way:
the call fails with an error naming that cause rather than navigating the
claimed tab or saving an empty file. Downloading such resources with the
user's cookies needs a browser-side download path and is follow-up work.
JavaScript dialogs use type-specific actions: alerts and before-unload dialogs
can be dismissed, confirms can be accepted or dismissed, and prompts require
text when accepted. The SDK also exposes the dialog message and prompt default
value.
## Transport
`QwenPlaywrightTransport` implements Playwright's `ConnectOverCDPTransport` and
handles only the browser-level adaptation Playwright requires:
- browser discovery and version responses;
- registering Qwen-controlled tabs as attached Playwright targets and binding
each Playwright `Page` by its exact CDP target id;
- Playwright session IDs mapped to Chrome tabs and child CDP sessions,
including the explicit target sessions created by Playwright's public
`newCDPSession(page)` API;
- popup, worker, iframe, and out-of-process iframe lifecycle.
Unknown browser-level commands fail as individual CDP requests; they do not
close the transport or get forwarded to an arbitrary tab.
Malformed target attachment data is a transport protocol violation: it closes
the Playwright connection and makes all objects from that session stale.
Page-level `Page`, `Runtime`, `DOM`, `Accessibility`, `Input`, `Network`,
`Fetch`, `Storage`, and `Emulation` commands and events pass through without
Qwen reimplementing them. Browser diagnostics retain a bounded in-memory view
of Playwright console events. HAR export is not included until a
product caller requires it.
Chrome reports downloads from an extension debugger target as `Page` events,
while Playwright consumes the corresponding browser-level events. The
transport translates only those event names and preserves their payloads; it
does not maintain a separate download state machine. Tab registration leaves
Chrome's download policy unchanged: the extension cannot call
`Page.setDownloadBehavior`, and the page events arrive after Playwright enables
the Page domain without that command.
The Qwen control plane retains operations that are not CDP, including
`openTabs`, `claimTab`, `session.name`, and `history.query`.
History defaults to Chrome's last 24 hours when `from` is omitted; an explicit
`from` includes older visits without requiring `to`.
Native Host messages sent to Chrome are limited to 1 MiB. Larger
backend-to-extension messages are split into bounded protocol chunks and
reassembled by the extension before dispatch. Extension responses must fit the
16 MiB bridge frame limit, measured as serialized UTF-8 bytes. An oversized
operation result returns a bounded `OPERATION_FAILED` response so the Native
Host and the browser session remain available for subsequent requests.
## Session model
The Node Kernel directly owns the local Chrome extension transport:
- one Browser Use session may be active for the current OS user;
- one session may control multiple tabs;
- a second session fails with `BROWSER_USE_BUSY`;
- closing the session closes still-controlled agent-created tabs, including
handoffs, releases claimed tabs, and then releases the local socket;
- a transport disconnect invalidates the current Playwright connection;
- tab-scoped objects from the disconnected connection fail with
`STALE_BROWSER_SESSION` and are never silently rebound.
- closing and reinitializing Browser Use creates a new SDK object generation;
handles retained from the closed generation remain stale.
On Unix, both endpoints prefer an existing private, user-owned
`/run/user/<uid>/bridge.sock`; otherwise they use
`/tmp/qwen-browser-use-<uid>/bridge.sock` (`/private/tmp` on macOS). The
backend creates a user-owned directory with mode `0700` and a socket with mode
`0600`. Both endpoints reject unsafe ownership, permissions, and replaceable
ancestors; the Native Host also rejects socket symlinks before forwarding any
traffic. An explicit socket override must use the same private-directory
boundary. Same-user processes remain inside the trust boundary.
When no backend is listening, the Native Host exits. The extension schedules
one retry using a 30-second Chrome alarm, which survives worker suspension;
it does not run a one-second retry loop or rewrite empty session state on
failed discovery. Initial backend discovery can wait up to 35 seconds, with
the normal request execution timeout starting after connection. Browser
listing and selection both allow this discovery window; explicit short
transport request timeouts still cap discovery.
The active `runtime.connectNative()` port keeps the worker alive on Chrome 105
and later, and an active `chrome.debugger` session provides an additional
keepalive on Chrome 118 and later. This differs from the separate `/cdp`
WebSocket bridge and follows Chrome's documented extension service-worker
lifecycle. A real-Chrome session must remain usable after more than 60 seconds
without Browser Use traffic.
When the backend socket disappears after connecting, the Native Host exits and
Chrome closes its Native Messaging port. The extension handles that port
disconnect by detaching the session's controlled tabs, removing Browser Use
overlays, clearing ownership and derived-tab state, ungrouping managed tabs
without closing them, and scheduling Native Host discovery for a future backend.
Debugger attach and detach operations are serialized per tab. A successful
release waits for Chrome to complete detach; a timeout in disconnect cleanup
does not discard an unfinished per-tab operation. If new-tab initialization
fails, the extension removes that newly created tab. Explicit user cancellation
of debugging releases ownership and the derived relationship, persists that
state, and ungroups the tab on a best-effort basis.
At the end of a browser turn, `tabs.finalize()` treats `keep` as the complete
set for that call: it closes unlisted agent-created tabs and releases unlisted
claimed tabs. Deliverable tabs remain open but are released; handoff tabs
remain open and controlled until the next finalization or runtime close. A
handoff that is still needed must be included again in the next turn. An
agent-created popup keeps that ownership if its opener closes before
finalization. The extension is the source of browser-side ownership, while the
runtime keeps the corresponding session projection; agent-created ownership
takes precedence if a derived tab is observed through both paths.
Re-registering a crashed Chrome tab replaces its stale runtime entry and
preserves that ownership. Cleanup forgets a tab already removed in Chrome,
while other close or release failures retain the entry for retry.
`tabs.finalize()` validates the complete `keep` set before closing anything. An
unknown, stale, or duplicate entry aborts finalization so a malformed keep list
cannot accidentally close a page the model intended to preserve.
Derived-tab synchronization attempts each attachment independently. If one
attachment or the discovery query fails, finalization still cleans up the other
known tabs according to their dispositions, then reports the failure. A failed
attachment does not authorize closing an unregistered tab.
The first release adds no separate Browser Use authorization or process
authentication layer.
The `qwen serve` `/cdp` bridge is not part of Browser Use. It is a serve-mode
tunnel for an external automation adapter and the active Chrome tab. Browser
Use instead operates in a normal Qwen Code session and provides multi-tab
discovery and Qwen-specific control operations that are not CDP.
The two paths are independent debugger clients and are mutually exclusive per
tab. Browser Use fails clearly when `/cdp`, DevTools, or another debugger
already owns a tab.
## Playwright code reuse
The implementation adapts the following Apache-2.0 Playwright sources from
revision
`350d24a344b07543fdc4014339a7871fd1c1b227`:
| Upstream file | Qwen use |
| ----------------- | ------------------------------------------------------------------------------------------- |
| `browserModel.ts` | Copy and adapt target discovery and browser-level CDP behavior. |
| `cdpRelayV2.ts` | Fold its dispatch/event logic into `QwenPlaywrightTransport`; omit its WebSocket handshake. |
The Native Messaging protocol and extension relay also carry Qwen-only
operations such as tab discovery and History.
The adapted code uses only public Playwright APIs, conforms to Qwen's strict
TypeScript rules, and fails closed on attachment errors. Copyright headers, the
Playwright source revision, and NOTICE are preserved in the Browser Use
package.
The Browser Use package pins `playwright-core@1.62.1` independently because the
custom CDP transport API and the pairing between
`ariaSnapshot({ mode: "ai" })` output and `aria-ref` locators are
version-sensitive. Every Playwright upgrade must pass a real Chrome smoke test
that takes an AI snapshot and acts on one of its returned refs. Existing
workspace consumers remain on their current Playwright versions; this feature
does not require a repository-wide upgrade.
Managed smoke scripts use Chromium or Chrome for Testing. Automatic discovery
excludes branded Google Chrome, and an explicit Google Chrome 137+ executable
is rejected because it cannot load the unpacked extension. Normal completion,
SIGINT, and SIGTERM stop the managed browser process group and remove its
temporary profile before the script exits.
The managed preflight validates screenshot MIME type and decoded JPEG clip
dimensions. The SauceDemo smoke checks checkout state and prices; source-code
mentions of input or finalization methods are not evidence that those actions
ran, so its result does not claim to verify trusted input or tab finalization.
Its independent completion check starts the transport, allows the full
35-second discovery window, and retries only `TAB_DEBUGGER_CONFLICT` while the
previous session releases its debugger attachment.
## Product decisions
For the first release:
- installing the Qwen Chrome extension authorizes Browser Use;
- first use on macOS or Linux automatically registers the Native Host without
a separate prompt;
- Browser Use may enumerate and claim top-level HTTP(S) tabs by default, and
`tab.goto` navigates claimed tabs to http(s) URLs only;
- History is declared with the other required extension permissions; there is
no Browser Use permission-management UI;
- there is no Browser Use-specific origin allowlist, upload-root allowlist, or
snapshot redaction in this release;
- the existing Qwen toolbar action and side panel remain;
- the model can discover Browser Use by default; users can disable it through
the existing `/skills` controls.
## Current boundaries and future work
- **Sessions:** One Browser Use session may be active per OS user, and that
session may control multiple tabs. Future support for concurrent sessions
must isolate tab ownership, event routing, cleanup, and reconnect behavior.
- **Turn cleanup:** Finalization is an explicit final browser action. Closing
the runtime provides a fallback, but interrupting a model turn does not close
the persistent runtime; still-controlled tabs remain managed until a later
finalization or runtime close. A transport disconnect releases them without
closing their pages. A future Qwen turn-lifecycle hook should invoke
finalization independently of model behavior.
- **Browser backends:** The Qwen extension currently connects the SDK to
Chrome. Other browser families or an in-app browser should be added together
with capability discovery when products need them.
- **Product control:** Authenticate the local connection. Skill enablement
controls availability, not direct SDK access or active browser sessions.
- **History:** Make Chrome History optional through a Qwen-owned grant and
revoke flow outside the side panel.
- **Platform and optional APIs:** Native Host installation currently supports
macOS and Linux. Windows support and optional APIs such as clipboard, page
assets, HAR, and read-only evaluate should be introduced independently when
a product workflow requires them.
## Dialog and navigation lifetimes
A dialog handle identifies the dialog instance returned by `getJsDialog`.
Accepting or dismissing an expired handle fails with `NOT_FOUND` and must not
act on a replacement dialog. Dialog ids are internal to the SDK protocol;
the public handle retains only its supported actions. Before-unload dialogs
support both accepting the navigation and dismissing it.
Chrome dialog-close events clear the runtime cache, including user actions
outside the SDK. Playwright hands a dialog over on a later turn than the
bridge reports its CDP events, so the runtime traces each tab's dialog
openings and closes in bridge order and drops a delivered dialog the bridge
has already reported closed; a close with no traced opening (a dialog open
before the tab was attached) is never charged to a later dialog. An opening
Playwright never delivers is retired once its delivery turn has passed, so it
cannot swallow a later dialog's charge. A dialog already open when the tab is
claimed emits no opening event at all; the runtime probes the renderer at
attach and, when the probe goes unanswered, gates the tab with `DIALOG_OPEN`,
with `getJsDialog` reporting it under a stable handle whose accept and dismiss
run over CDP. An
`expectNavigation` waiter
is released when either its action or its wait fails, including rejection by
the dialog gate before the wait implementation runs.
## Input completion
Locator fill delegates to Playwright, including its native input/change event
behavior. The runtime does not add a second change event after a successful
fill. Text-like inputs therefore commit change on blur; date-like inputs use
Playwright's existing change dispatch.
The typing diagnostic retains the original DOM element and its value in a
page-side handle. It reports `INPUT_BLOCKED` only while that editable element
remains connected and focused with the same value. Navigation, replacement,
or a non-editable keyboard target cannot turn successful input into this
error. The handle is disposed after both successful and failed input.
Modifier cleanup attempts to release every attempted key in reverse order even
after a failed keydown or keyup, and cleanup failures are discarded. Cleanup
never rewrites a completed action into a failure; only the action's own error
propagates.
## Attachment and session shutdown
BrowserModel owns Chrome debugger attachments, including attachments still in
flight. Attachment and release are serialized per provider tab; close rejects
new attachments and waits for admitted work before releasing owned tabs.
Explicit CDP session detach emits the parent-scoped target-detached event that
Playwright uses to dispose its session listeners.
Stopping the runtime unsubscribes session listeners, drains tab registration,
finalizes controlled tabs, and awaits transport cleanup before stopping the
bridge. Shutdown closes extension-reported derived tabs without admitting new
debugger attachments. Page close and crash release through the transport that
registered the page; a crashed tab retains its ownership until it is closed or
finalized. Reconnecting waits for the previous transport cleanup so old releases
cannot detach newly claimed tabs. A request cannot implicitly restart a stopped
bridge.
The adapter supplies a stable default-context id when CDP omits its optional
browserContextId, preserving supplied ids and rejecting malformed values
before Playwright receives the target. Direct message callback failures close
the transport and release its attachments.
## Contract validation
Regression checks must reject zero operation timeouts while preserving zero
delays, omitted defaults, and the 120,000 ms upper bound. Locator checks cover
all four recursive edges, the depth boundary, flat 32-step plans, and ordinary
composition in Chrome. Evaluation checks cover argument rejection, page-side
result rejection for all three APIs, actual TypeScript consumers, valid JSON
controls, repeated references, and top-level undefined normalization. Removing
the corresponding guard must make the regression checks fail.

View file

@ -0,0 +1,222 @@
# 基于 Playwright Core 的 Browser Use
[English](browser-use.md) | [简体中文](browser-use.zh-CN.md)
## 目标
Browser Use 为模型提供结构化 API,使其能够从 Qwen Code 控制用户现有的 Chrome。
- Browser SDK 作为库运行在 Node REPL MCP server 暴露的持久 Node Kernel 内。
- `playwright-core` 提供标准浏览器自动化语义。
- Qwen Chrome 扩展和 `chrome.debugger` 将运行时连接到 Chrome。
- 首个版本支持一个活跃的 Browser Use 会话。
## 架构
```mermaid
flowchart TB
Agent["Qwen Code agent"] -->|"MCP"| Repl["Node REPL MCP server"]
Repl -->|"执行 cell"| SDK
subgraph Kernel["持久 Node Kernel 进程"]
direction TB
SDK["Browser SDK"]
SDK --> Runtime["Browser runtime<br/>校验、生命周期、预算"]
Runtime -->|"Playwright 操作"| Playwright["playwright-core"]
Playwright --> PWTransport["QwenPlaywrightTransport"]
PWTransport --> ExtensionTransport["ChromeExtensionTransport"]
Runtime -.->|"Qwen 控制和截图"| ExtensionTransport
end
ExtensionTransport -->|"本地 socket"| Host["Native Host"]
Host -->|"Chrome Native Messaging"| Extension["Qwen Chrome 扩展"]
Extension -->|"CDP 和扩展 API"| Chrome["用户现有的 Chrome<br/>标签页、配置、登录状态"]
```
标准浏览器操作经过 Playwright。浏览器控制操作和截图获取共用同一条 Native Messaging 路径,但绕过 Playwright 的浏览器级 CDP 适配器。
`playwright-core@1.62.1` 通过 `chromium.connectOverCDP(transport)` 接受公共自定义 CDP transport。因此 Qwen 保留 Native Messaging,不添加本地 WebSocket server。
Browser Use 以内置 skill 及其运行时资源随 Qwen Code 一起发布,无需单独安装 Browser Use 运行时包或第二个 Qwen 扩展,复用现有的 Qwen Chrome 扩展。Skill 的 `runtime/` 目录包含 Browser SDK、Native Host 和固定版本的 Playwright 依赖。Skill 向现有 Node REPL 注册 `runtime/node_modules` 并导入 `runtime/index.js`;CLI 本身不执行浏览器逻辑。kernel 在没有 Node 全局对象的隔离 realm 中执行该导入,因此运行时 bundle 在开头通过 `createRequire` 自行绑定 `process`;构建时的加载检查运行在主 realm,无法发现缺失的绑定,故由一个基于 kernel 的测试固定该行为。源码开发、转译构建和发布的 CLI 使用同一布局。仍需配置通用 Node REPL MCP server,并在浏览器安装 Qwen Chrome 扩展。内置资源不会在 CLI 启动时连接 Chrome;SDK 在首次使用时才连接。
没有任何构建步骤会把该运行时写进源码树:打包步骤把它复制到 `dist/bundled/browser-use/runtime` 供发布的 CLI 使用;`npm run dev` 把同样的文件复制到源码 `SKILL.md` 旁边(git 忽略的位置),因为只有开发模式会从源码树读取内置 skill;正常安装的 `prepare` hook 负责构建这些副本的来源包。修改 Browser Use 源码或依赖后,运行 `npm run build --workspace=@qwen-code/browser-use` 并重新启动 `npm run dev` 以刷新开发副本;CLI 和 Core 仍直接运行 TypeScript 源码。若运行时缺失,调用 skill 时已有的 setup 检查会报告资源不完整。
Browser Use 默认对模型可用,由模型根据用户任务选择。用户可通过 `/skills` 或 `skills.disabled` 禁用,使用与 Computer Use 相同的控制方式。禁用的 skill 不参与模型发现和 skill 调用。这是 Computer Use 也使用的通用 skill 机制,不是浏览器权限边界:禁用 skill 不会移除已有对话中的指令,也不会断开现有 SDK 会话。
Native Host 注册属于本机产品初始化,不由 Chrome 扩展执行。在 macOS 和 Linux 上,Browser runtime 首次初始化时检查 Google Chrome、Chrome for Testing 和 Chromium 的标准 `Default`、`Profile N` profile 中是否安装 Qwen 扩展。它读取 `Secure Preferences` 或 `Preferences` 中的扩展注册信息,并确认 manifest 存在,同时支持打包和解压安装。仅有残留扩展目录不视为已安装。若未找到扩展,初始化会提示安装方式,不写入 Native Host 文件。检测成功后,为已存在的浏览器根目录幂等安装 launcher 和 manifest。配置了 `QWEN_BROWSER_USE_SOCKET_PATH` 时继续使用外部管理的安装。
安装 Qwen Chrome 扩展即同意首次使用时自动完成上述本机配置。Qwen 退出后,launcher 和 Native Messaging 注册仍然保留。安装器拒绝覆盖其他程序的文件:launcher 冲突会终止初始化,浏览器 manifest 冲突则跳过该 manifest,并在 stderr 上给出指明该文件的警告。运行 `node <skill-base>/runtime/scripts/native-host-setup.js uninstall` 可删除 Browser Use 拥有的文件;`status` 检查这些文件,`install` 显式注册。若要阻止后续 Browser Use 初始化时自动注册,还需卸载 Chrome 扩展。只有文件不存在才视为缺失;其他读取失败会终止操作,不覆盖无法读取的文件。Chrome 扩展仅通过 `connectNative()` 打开已注册的 host。
## 职责
| 组件 | 职责 |
| -------------------------- | --------------------------------------------------------------------- |
| Node REPL | 通用进程隔离、持久绑定、取消和输出预算,不包含浏览器逻辑。 |
| Browser SDK | 面向模型的任务 API;私有适配器执行 JSON 边界约束,不暴露传输细节。 |
| Browser runtime | 命令校验、Qwen 标签页生命周期、截图获取、输出预算和诊断。 |
| `playwright-core` | Locator、AI 无障碍快照和 ref、frame、导航等待、操作和事件。 |
| `QwenPlaywrightTransport` | 将 Playwright 浏览器级 CDP 连接适配到 Qwen 标签页和子会话标识。 |
| `ChromeExtensionTransport` | 拥有本地 socket,直接与 Native Host 交换请求和事件。 |
| Native Host | 在本地 socket 和 Chrome Native Messaging 之间转发带帧消息的最小中继。 |
| Chrome 扩展 | Chrome 权限、`chrome.debugger` 附加和 CDP 转发。 |
## Browser SDK
Browser SDK 是面向模型的对象 API。`BrowserAgent` 选择浏览器后端,`Browser` 提供用户标签页发现和管理,每个 `Tab` 暴露导航、截图、对话框以及三种交互方式。当前后端通过 Qwen 扩展控制用户的 Chrome。
SDK 与浏览器运行时共用内部命令契约。SDK 对象将模型调用转换为经过校验的命令,运行时通过 Playwright `Page` 或截图/控制适配器执行这些命令。SDK 不暴露 Playwright 对象、CDP 会话或传输细节。
每个 SDK 对象绑定到创建它的运行时会话。关闭运行时会使其 Agent、Browser、Tab 和 Locator 对象失效;初始化新运行时不会把旧对象重新指向新后端。
面向模型的 API 结构参考 Codex Browser Use SDK。三种标签页交互 API 分别对应模型识别目标的三种依据:语义页面结构、DOM 快照节点和视觉坐标。这种划分基于目标定位依据,而非实现方式,也不是 API 兼容层。
三种交互方式共用 Playwright 自动化引擎:
| API | 定位依据 | Playwright 实现 |
| ---------------- | -------------- | ------------------------------------ |
| `tab.playwright` | 语义页面结构 | `Page`、`Locator` 和 `FrameLocator` |
| `tab.dom_cua` | 快照 `node_id` | AI 无障碍快照和 `aria-ref` locator |
| `tab.cua` | 视口坐标 | 鼠标和键盘输入,辅助鼠标按钮使用 CDP |
可以用语义描述元素时使用 `tab.playwright`;模型从 DOM 快照识别目标时使用 `tab.dom_cua`;从截图视觉定位目标时使用 `tab.cua`。
扩展为坐标鼠标输入显示短暂的指针 overlay,但该装饰仅尽力提供,绝不延迟输入命令本身。其 DOM 节点在鼠标输入时创建,指针到期后移除;只读检查不会创建 overlay 节点。扩展创建并持有自己的节点,不会接管页面中同 ID 的元素。
浏览器操作在后台运行。新标签页不会替换用户当前的活跃标签页,输入操作也不会将 Chrome 置于前台。页面焦点模拟使后台渲染和输入保持活跃,而不改变桌面焦点。
页面和 locator 的 evaluation 接受函数或字符串。函数按文档规定的参数调用;字符串返回 JavaScript `eval` 的完成值,支持尾部分号、注释和语句。字符串求值保留 SDK 的词法绑定 `arg`、`element` 和 `elements`。值为函数的字符串不会被调用。需要 `await` 时使用异步函数参数,在字符串中使用对象字面量时加括号。Evaluation 的超时覆盖整个调用,包括元素查找,并返回 `OPERATION_TIMEOUT`。显式操作超时必须为 1 到 120,000 ms 的整数;零值以 `INVALID_ARGUMENT` 拒绝。省略超时时保留各操作的默认值。仅用于延时的 `waitForTimeout` 接受 0 到 120,000 ms,其中零表示不等待。超时结束调用方的等待,不会终止已在页面运行的 JavaScript。
Evaluation 接受 JSON 数据:有限数、字符串、布尔值、null、数组和普通记录,包括 TypeScript readonly 数据。公开的 `JsonSerializable` 类型约束参数和结果。非 JSON 参数在 dispatch 前拒绝;结果在页面内、Playwright 序列化之前校验。运行时传输编码后的 JSON 文本,使 `__proto__` 等对象键保留数据含义。嵌套 undefined、非有限数、Date、RegExp、函数和循环引用会被拒绝,不会静默转换。省略的顶层参数保持 undefined;顶层 undefined 结果转换为 null,返回类型也反映这一行为。类型为 void 的回调可能只是丢弃了实际返回值,因此其返回类型使用更宽的 JsonSerializable,不承诺一定为 null。
Locator plan 的每个数组最多包含 32 步,最多嵌套 32 层,顶层数组计为第一层。深度上限同样适用于 `and`、`or`、`filter.has` 和 `filter.hasNot`。超过深度的 plan 在浏览器启动前以 `INVALID_ARGUMENT` 校验失败;正常 locator 组合仍受支持。
输入操作与导航等待使用独立超时。Locator 点击、按键和 DOM CUA 点击关闭 Playwright 隐式的操作后导航等待。操作超时覆盖输入执行;`expectNavigation()` 在操作前注册监听器,并使用自己的超时等待请求的导航状态。输入成功不代表目标页面已加载。短暂且有上限的 renderer drain 允许排队的输入处理器运行,而不等待新的页面上下文。
`tab.playwright.domSnapshot()` 和 `tab.dom_cua.get_visible_dom()` 返回相同的 Playwright AI 无障碍快照,仍受现有 20,000 字符预算约束。两者都不按角色或光标样式过滤,因为这些提示无法可靠地区分可点击元素与静态内容。因此快照除控件外也包含静态文本和容器。ref 用于标识节点,并不保证点击该节点会产生动作。DOM CUA 操作通过 Playwright 的 `aria-ref` locator 解析这些 id。ref 仅在签发它的快照仍然有效时可解析:Playwright 在每个新文档上重新从 e1 开始编号 ref,因此主 frame 导航会使更早快照的 ref 失效。由于快照文本格式与版本有关,适配器及其测试固定使用同一 Playwright 版本。
Playwright 公共 CDP session API 提供坐标 CUA 的按钮 4(后退)和 5(前进);较高层的 Playwright mouse API 不暴露它们。快照截断、截图编码和预算、会话失效检测及 JSON 传输封装属于运行时实现细节,不作为面向模型的选项。
视口截图返回 JPEG 字节、MIME 类型及元数据。元数据包含原始图像尺寸、视口、设备像素比和 CSS 像素坐标空间,使模型客户端缩放预览后视觉坐标仍可用。Skill 将完整截图传给 `nodeRepl.emitImage()`。元数据随图像事件传递,紧邻每张保留图像之前返回,独立于普通文本输出预算。被拒绝或省略的图像不会遗留元数据。没有元数据专用大小上限;已有协议帧和客户端输出限制仍然适用。把 Node REPL MCP server 随 Qwen Code 一起打包留待后续工作。该协议支持自 `@qwen-code/node-repl-mcp` 0.1.6 起首次发布;已发布的 0.1.2 至 0.1.5 包不具备该支持,因此 Browser Use skill 固定使用该版本而非 `latest`。视口截图根据编码字节数限制,不会仅因视口尺寸而拒绝。显式 clip 和整页截图保留像素预算,因为其尺寸由调用方控制或可能无界。从页面脚本探测到的设备像素比只在真实 Chrome 窗口可能报告的范围内被信任;当截图像素与请求的 CSS 区域不一致时,运行时根据 Chrome 自身的输出推算真实像素比并重拍一次。
截图获取采用 Codex Browser Use 策略,独立于 Playwright 的截图准备。短暂且有上限的渲染同步让待处理绘制在截图前完成。普通视口截图请求新的 CDP screencast 帧,帧期限为两秒,然后回退到命令超时为五秒的 `Page.captureScreenshot`。Clip 和整页截图直接使用后者。请求之前的旧帧会被丢弃;每个标签页的截图串行执行,事件监听器和 screencast 均会清理。运行时拥有这些事件,Playwright 不会重复确认同一帧。图像采用 JPEG quality 80,保留 CSS 像素坐标,不需要激活标签页或将 Chrome 置于前台。单次截图超时不会断开浏览器会话。
Locator `downloadMedia()` 触发媒体或文件链接下载;`waitForEvent("download")` 用于同步其他页面操作触发的下载。返回的下载对象不透明,不暴露宿主文件系统路径。
`downloadMedia()` 是 Qwen 适配功能,因为 Playwright 没有等价 locator 方法。Qwen 通过 Playwright locator 解析元素,为媒体 URL 临时创建页面内下载链接,点击后立即删除。调用方通过 Playwright `download` 事件同步。媒体字节在页面源内读取,因此服务端未返回 CORS 头的跨源资源无法用这种方式下载:调用会以指明该原因的错误失败,而不会让受控标签页跳转或保存空文件。带用户 cookie 下载此类资源需要浏览器侧的下载路径,属于后续工作。
JavaScript 对话框使用类型相关操作:alert 和 before-unload 可以 dismiss,confirm 可以 accept 或 dismiss,prompt 在 accept 时需要文本。SDK 同时暴露对话框消息和 prompt 默认值。
## 传输
`QwenPlaywrightTransport` 实现 Playwright 的 `ConnectOverCDPTransport`,仅处理 Playwright 所需的浏览器级适配:
- 浏览器发现和版本响应;
- 将 Qwen 控制的标签页注册为已附加的 Playwright target,并通过精确 CDP target id 绑定每个 Playwright `Page`;
- 将 Playwright session ID 映射到 Chrome 标签页和子 CDP 会话,包括公共 `newCDPSession(page)` API 创建的显式 target session;
- Popup、worker、iframe 和跨进程 iframe 的生命周期。
未知的浏览器级命令作为单个 CDP 请求失败,不会关闭 transport,也不会转发给任意标签页。格式错误的 target attachment 数据属于传输协议违规:会关闭 Playwright 连接并使该会话的所有对象失效。
页面级 `Page`、`Runtime`、`DOM`、`Accessibility`、`Input`、`Network`、`Fetch`、`Storage` 和 `Emulation` 命令及事件直接转发,Qwen 不重新实现它们。浏览器诊断保留有上限的 Playwright console 事件内存视图。在产品调用方提出需求前,不包含 HAR 导出。
Chrome 将扩展 debugger target 的下载报告为 `Page` 事件,而 Playwright 消费对应的浏览器级事件。Transport 仅翻译这些事件名并保留载荷,不维护单独的下载状态机。注册标签页时保留 Chrome 的下载策略:扩展无法调用 `Page.setDownloadBehavior`,而 Playwright 启用 Page 域后,无需该命令即可收到页面下载事件。
Qwen 控制面保留非 CDP 操作,包括 `openTabs`、`claimTab`、`session.name` 和 `history.query`。History 省略 `from` 时沿用 Chrome 最近 24 小时的默认范围;显式传入 `from` 可查询更早的访问记录,无需同时指定 `to`。
Native Host 发往 Chrome 的消息上限为 1 MiB。较大的后端到扩展消息拆分为有界协议分块,由扩展在 dispatch 前重新组装。扩展响应必须满足 16 MiB 的 bridge 帧限制,按序列化后的 UTF-8 字节数计算。超大的操作结果返回有界的 `OPERATION_FAILED` 响应,Native Host 和浏览器会话仍可处理后续请求。
## 会话模型
Node Kernel 直接拥有本地 Chrome 扩展 transport:
- 每个操作系统用户最多有一个活跃的 Browser Use 会话;
- 一个会话可以控制多个标签页;
- 第二个会话以 `BROWSER_USE_BUSY` 失败;
- 关闭会话会关闭仍受控的 agent 创建标签页(包括 handoff),释放认领的标签页,然后释放本地 socket;
- Transport 断开会使当前 Playwright 连接失效;
- 断开连接的标签页对象以 `STALE_BROWSER_SESSION` 失败,绝不静默重绑;
- 关闭并重新初始化 Browser Use 会创建新一代 SDK 对象,旧一代保留的句柄继续失效。
在 Unix 上,两端优先使用已存在、私有且由当前用户拥有的 `/run/user/<uid>/bridge.sock`;否则使用 `/tmp/qwen-browser-use-<uid>/bridge.sock`(macOS 为 `/private/tmp`)。后端创建当前用户拥有、权限为 `0700` 的目录,以及权限为 `0600` 的 socket。两端都会拒绝不安全的 ownership、权限及可被替换的祖先目录;Native Host 还会在转发流量前拒绝 socket 符号链接。显式 socket 路径覆盖也必须遵守相同的私有目录边界。同一用户的进程仍处于信任边界内。
没有后端监听时,Native Host 退出。扩展使用 30 秒 Chrome alarm 安排一次重试,可跨 worker 挂起保留;发现失败不会启动每秒重试循环或重写空会话状态。后端首次发现最多等待 35 秒,连接建立后才开始常规请求执行超时。浏览器列表和选择都允许这个发现窗口;显式指定的短 transport 请求超时仍会限制发现等待。Chrome 105 及以上的活跃 `runtime.connectNative()` port 会保持 worker 存活,Chrome 118 及以上的活跃 `chrome.debugger` 会话还提供额外保活。这与独立的 `/cdp` WebSocket bridge 不同,遵循 Chrome 文档规定的扩展 service-worker 生命周期。真实 Chrome 会话必须在超过 60 秒无 Browser Use 流量后仍可使用。
连接建立后若后端 socket 消失,Native Host 退出,Chrome 关闭其 Native Messaging port。扩展处理 port 断开时,会 detach 会话控制的标签页、移除 Browser Use overlays、清除 ownership 和派生标签页状态、取消托管标签页分组而不关闭页面,并安排 Native Host 发现以连接未来的后端。Debugger attach 和 detach 按标签页串行执行。成功释放会等待 Chrome 完成 detach;断线清理超时不会丢弃未完成的单标签页操作。新标签页初始化失败时,扩展会删除该新标签页。用户显式取消调试时,扩展释放 ownership 和派生关系,持久化状态,并尽力取消分组。
每轮浏览器操作结束时,`tabs.finalize()` 将 `keep` 视为本次调用的完整保留集合:关闭未列出的 agent 创建标签页,释放未列出的认领标签页。Deliverable 标签页保持打开但释放控制;handoff 标签页保持打开并受控,直到下一次 finalization 或关闭 runtime。下一轮仍需使用的 handoff 必须再次列入。Agent 创建的 popup 在 opener 先于 finalization 关闭时仍保留该 ownership。扩展是浏览器侧 ownership 的权威来源,runtime 维护对应的会话视图;从两条路径观察到派生标签页时,agent 创建的 ownership 优先。重新注册崩溃的 Chrome 标签页会替换其过期 runtime 条目并保留该 ownership。清理时会移除 Chrome 中已不存在标签页的条目;其他关闭或释放失败则保留条目以便重试。
`tabs.finalize()` 在关闭任何页面前校验整个 `keep` 集合。未知、过期或重复条目会终止 finalization,避免格式错误的保留列表意外关闭模型想保留的页面。派生标签页同步会独立尝试每次 attachment。某次 attachment 或发现查询失败时,finalization 仍按 disposition 清理其他已知标签页,然后报告失败。Attachment 失败不代表有权关闭尚未注册的标签页。
首个版本不添加独立的 Browser Use 授权或进程认证层。
`qwen serve` 的 `/cdp` bridge 不属于 Browser Use。它是 serve 模式下供外部自动化适配器与活跃 Chrome 标签页使用的隧道。Browser Use 在普通 Qwen Code 会话运行,提供多标签页发现和 Qwen 特有的非 CDP 控制操作。
两条路径是独立 debugger 客户端,在单个标签页上互斥。当 `/cdp`、DevTools 或其他 debugger 已拥有标签页时,Browser Use 会明确失败。
## Playwright 代码复用
实现适配以下 Apache-2.0 Playwright 源码,来源 revision 为 `350d24a344b07543fdc4014339a7871fd1c1b227`:
| 上游文件 | Qwen 用途 |
| ----------------- | ------------------------------------------------------------------------------ |
| `browserModel.ts` | 复制并适配 target 发现和浏览器级 CDP 行为。 |
| `cdpRelayV2.ts` | 将其 dispatch/事件逻辑整合到 `QwenPlaywrightTransport`,省略 WebSocket 握手。 |
Native Messaging 协议和扩展 relay 还承载 Qwen 特有的标签页发现、History 等操作。
适配代码仅使用公共 Playwright API,遵循 Qwen 严格 TypeScript 规则,attachment 出错时关闭而不继续使用。Browser Use 包保留版权头、Playwright source revision 和 NOTICE。
Browser Use 包独立固定 `playwright-core@1.62.1`,因为自定义 CDP transport API,以及 `ariaSnapshot({ mode: "ai" })` 输出与 `aria-ref` locator 的配对均与版本有关。每次升级 Playwright 都必须通过真实 Chrome 冒烟测试:生成 AI 快照,并通过其中返回的 ref 执行操作。现有 workspace 消费方保留当前 Playwright 版本;该功能不需要全仓升级。
Managed 冒烟脚本使用 Chromium 或 Chrome for Testing。自动发现不选择普通 Google Chrome;显式指定的 Google Chrome 137+ 因无法加载未打包扩展而被拒绝。正常结束、SIGINT 和 SIGTERM 均在脚本退出前停止托管浏览器进程组并删除临时 profile。
Managed preflight 验证截图 MIME 类型及 JPEG 解码后的 clip 尺寸。SauceDemo 冒烟测试检查结账状态和价格;源码中出现输入或 finalization 方法名称不能证明执行过这些操作,因此该测试结果不声称验证了可信输入或标签页 finalization。独立的完成状态检查先启动 transport,允许完整的 35 秒发现窗口,并仅在前一会话释放 debugger attachment 期间重试 `TAB_DEBUGGER_CONFLICT`。
## 产品决策
首个版本:
- 安装 Qwen Chrome 扩展即授权 Browser Use;
- macOS 和 Linux 首次使用时自动注册 Native Host,无需单独提示;
- Browser Use 默认可以枚举和认领顶层 HTTP(S) 标签页,`tab.goto` 只允许把已认领标签页导航到 http(s) URL;
- History 与其他必需扩展权限一起声明,不提供 Browser Use 权限管理 UI;
- 不提供 Browser Use 专用 origin allowlist、上传根目录 allowlist 或快照脱敏;
- 保留现有 Qwen 工具栏操作和侧边栏;
- 模型默认可发现 Browser Use;用户可通过现有 `/skills` 控制禁用。
## 当前边界与后续工作
- **会话:** 每个操作系统用户最多一个活跃 Browser Use 会话,该会话可控制多个标签页。未来支持并发会话时,必须隔离标签页 ownership、事件路由、清理和重连行为。
- **轮次清理:** Finalization 是显式的最后一个浏览器操作。关闭 runtime 提供兜底,但中断模型轮次不会关闭持久 runtime;仍受控的标签页会保持托管,直到后续 finalization 或 runtime 关闭。Transport 断开会释放控制但不关闭页面。未来应由 Qwen 轮次生命周期 hook 独立于模型行为触发 finalization。
- **浏览器后端:** 当前 Qwen 扩展把 SDK 连接到 Chrome。当产品需要其他浏览器家族或内置浏览器时,应同时添加能力发现。
- **产品控制:** 认证本地连接。Skill 启用状态控制可用性,不控制直接 SDK 访问或活跃浏览器会话。
- **History:** 在侧边栏之外,通过 Qwen 管理的授予和撤销流程将 Chrome History 改为可选权限。
- **平台与可选 API:** Native Host 安装目前支持 macOS 和 Linux。Windows 支持,以及剪贴板、页面资源、HAR、只读 evaluate 等可选 API,应在产品工作流需要时独立引入。
## 对话框与导航生命周期
对话框句柄标识 `getJsDialog` 返回的具体对话框实例。Accept 或 dismiss 已过期句柄以 `NOT_FOUND` 失败,不能作用于替代对话框。Dialog id 属于 SDK 内部协议;公共句柄只保留其支持的操作。Before-unload 对话框支持接受导航和取消导航。
Chrome 的 dialog-close 事件清理运行时缓存,包括 SDK 之外的用户操作。Playwright 交付对话框的时机晚于 bridge 上报其 CDP 事件的轮次,因此运行时按 bridge 顺序记录每个标签页的对话框打开与关闭事件,并丢弃 bridge 已报告关闭的对话框;没有对应打开记录的关闭事件(标签页附着前就已打开的对话框)不会被记到后续对话框头上。Playwright 从未交付的打开记录会在其交付轮次过后被回收,不会再吞掉后续对话框的配对。附着时就已经打开的对话框完全不会产生打开事件;运行时在附着时探测渲染进程,探测无应答时以 `DIALOG_OPEN` 门控该标签页,`getJsDialog` 以稳定句柄报告该对话框,其 accept 与 dismiss 通过 CDP 执行。`expectNavigation` waiter 在 action 或等待失败时释放,包括在等待实现运行前被 dialog gate 拒绝的情况。
## 输入完成
Locator fill 委托给 Playwright,包括其原生 input/change 事件行为。运行时不会在 fill 成功后额外发送第二次 change 事件。因此文本类输入框在失焦时提交 change;日期类输入使用 Playwright 现有的 change 事件发送行为。
输入诊断通过页面内 handle 保留原始 DOM 元素及其值。只有可编辑元素仍连接、仍聚焦且值未变时才报告 `INPUT_BLOCKED`。导航、元素替换或不可编辑键盘目标不会把成功输入改报为该错误。输入成功或失败后均释放 handle。
Modifier 清理会按相反顺序尝试释放所有尝试按下的键,即使 keydown 或 keyup 失败,且清理失败会被丢弃。清理永远不会把已完成的操作改写为失败;只有操作自身的错误会向外传播。
## 附加与会话关闭
BrowserModel 拥有 Chrome debugger attachment,包括仍在进行中的附加。每个 provider tab 的附加和释放串行化;关闭时拒绝新的附加,并等待已接纳的工作完成后释放拥有的标签页。显式 CDP session detach 发送以父会话为作用域的 target-detached 事件,Playwright 据此释放 session 监听器。
停止运行时会取消 session 监听、排空标签页注册、finalize 受控标签页,并在停止 bridge 前等待 transport 清理。关闭过程中会关闭扩展报告的派生标签页,不接纳新的 debugger attachment。页面关闭和崩溃通过注册该页面的 transport 释放;崩溃的标签页保留 ownership,直到关闭或 finalize。重连等待前一个 transport 清理完成,防止旧的释放操作 detach 新认领的标签页。请求不能隐式重启已停止的 bridge。
当 CDP 省略可选 browserContextId 时,适配器提供稳定的默认 context id;保留已有 id,并在 Playwright 接收 target 前拒绝格式错误的值。直接消息回调失败会关闭 transport 并释放其 attachment。
## 契约验证
回归检查必须拒绝零操作超时,同时保留零延时、省略时的默认值和 120,000 ms 上限。Locator 检查覆盖四种递归边、深度边界、32 步平铺 plan,以及 Chrome 中的普通组合。Evaluation 检查覆盖参数拒绝、三个 API 在页面内的结果拒绝、实际 TypeScript 消费方、有效 JSON 对照、重复引用和顶层 undefined 归一化。移除相应保护后,回归检查必须失败。

View file

@ -0,0 +1,159 @@
# Daemon ACP Capacity Admission — PR1
[English](daemon-acp-capacity-admission-pr1.md) | [简体中文](daemon-acp-capacity-admission-pr1.zh-CN.md)
## 1. Status and scope
Design proposal dated 2026-09-15, checked against `df864bea6a930d31faf8b35fc5e57bac98a25e20`. Implemented as an opt-in mode in the accompanying change. It records the updated discussion: reuse the existing startup budget to limit ACP process admission, then add idle reclamation and user-directed closure in separate PRs.
PR1 provides an opt-in process-count limit and an actionable capacity error. It does not adjust child heap flags, sample free memory for admission, reclaim idle workspaces, interrupt existing work, or implement a workspace chooser. The existing reaper continues to operate independently. The requested simplification applies to the future reclamation mechanism; PR1 reuses the existing synchronous reservation sequence without adding a lock, queue, or distributed admission protocol.
Suggested implementation PR title: `feat(serve): add budget-based ACP child admission`.
The three-stage proposal is tracked in [#11907](https://github.com/QwenLM/qwen-code/issues/11907). This document specifies its first stage; idle reclamation and user-directed closure remain separate follow-ups.
[#8182](https://github.com/QwenLM/qwen-code/issues/8182) is open and still requires fixed-heap enforcement plus representative evidence. [#11386](https://github.com/QwenLM/qwen-code/issues/11386) is closed, as read on 2026-09-15. PR1 is related follow-up work; use `Related to #8182` and reference #11386 as background, without closing keywords. Count admission alone does not complete #8182.
## 2. Current behavior and gap
| Existing component | Verified behavior | PR1 use |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `resolveDaemonMemoryBudget` | Resolves the host/cgroup total, configured/effective budget, root reserve and child pool at startup. | Reuse the values and rounding rules unchanged. |
| `createChildHeapPolicy` | `off` publishes no partition; default `observe` calculates a fixed partition and counts hypothetical refusals. | Add a count-admission mode using the same calculated limit. |
| `ProcessRegistry` | Counts attached children and outstanding spawn reservations; tracked teardown remains counted until registry release. | Use `committedProcessCount`, not registered workspaces or live channel count. |
| `createSpawnChannelFactory` | Reserves before checking the policy; ignores the policy's refusal today; cancels the reservation on a synchronous error. | Reject inside this existing sequence before `spawn()`. |
| `runQwenServe` | Shares a registry and policy across primary, startup-secondary and dynamically constructed workspace factories. | Apply one daemon-wide limit across these factories. |
| Session/channel reapers | Already close eligible abandoned sessions and empty ACP channels. Connected sessions are not merely abandoned sessions. | Retain current behavior; explicit capacity-driven reclamation belongs to PR2. |
| REST/ACP/UI errors | Capacity causes can be hidden by runtime/standalone wrappers; some Web Shell 503 load failures enter reconnect retries. | Preserve the specific cause and stop automatic capacity retries. |
An ACP can retain several sessions. Opening another session in an existing child does not necessarily need another process slot. Conversely, preheat, MCP operations, channel replacement, restoration and background work can start a child without a new workspace registration. Admission belongs at the common child-spawn boundary.
### 2.1 Budget calculation
With default configuration on the previously measured 7265 MiB host:
| Quantity | Calculation | Result |
| ------------------------- | -------------------------------------------- | -------- |
| Effective budget | `floor(7265 * 0.5)` | 3632 MiB |
| Root reserve | `clamp(floor(3632 * 0.1), 256, 1024)` | 363 MiB |
| Child pool | `3632 - 363` | 3269 MiB |
| Modeled slots | `min(floor(3269 / 512), 25)` | 6 |
| Modeled per-child ceiling | `min(floor(3269 / 6), legacyChildCeilingMb)` | 544 MiB |
Reuse the complete existing implementation, including explicit `--memory-budget-mb`, host/cgroup capping, tiny-host handling and the legacy ceiling check. Do not reimplement the formula in routes or the frontend. If no partition meets the 512 MiB model floor, the existing model produces zero slots and a null ceiling.
Only the slot count becomes an admission limit in PR1. The 544 MiB value remains modeled; actual child heap arguments still follow `getAcpMemoryArgs()`. Six slots mean a configured concurrency limit, not proof that six arbitrary workloads fit in RAM. Startup `availableMemoryMb` is the resolved host/container total, not a live free-memory reading. Existing heap calibration remains relevant to a later fixed-heap change, but is not a prerequisite for this separate count-only mode.
## 3. Configuration and compatibility
Extend the existing `--child-heap-mode` option and `ChildHeapMode` with `admit`; add no environment variable, standalone budget formula, per-workspace divisor or separate maximum-process setting.
| Mode | Calculates/reports partition | Rejects excess ACP starts | Applies modeled heap ceiling |
| ----------------------------- | ---------------------------- | ------------------------- | ---------------------------- |
| `off` | No | No | No |
| `observe` (unchanged default) | Yes | No | No |
| `admit` (new, opt-in) | Yes | Yes | No |
Example after implementation: `qwen serve --child-heap-mode admit`. An existing `--memory-budget-mb` override affects the modeled limit through the current calculation and also retains its existing journal-budget effects. It is not a dedicated concurrency knob. Budget and mode remain fixed for the daemon lifetime; switching requires restart. Do not introduce `enforce` in PR1 or imply that `admit` enforces a heap/RSS budget.
Update the fast parser, full CLI choices/help, programmatic options, policy type and SDK status mirror together. Retain `off`/`observe` behavior, legacy child arguments, workspace registration limits and per-workspace/global session limits. Upgrading without opting in must not introduce capacity rejection.
Support the managed `runQwenServe` spawn path. Reject `admit` with an injected bridge or an unwired direct-embed factory rather than silently accepting an ineffective option. A public admitting spawn factory must receive an explicit shared registry. Direct `createServeApp` callers without managed admission wiring must reject `admit`; pass the actual shared registry/policy references through the managed server construction, rather than treating a status-only snapshot callback as proof of enforcement. Keep ordinary direct-embed, IDE and standalone `qwen --acp` behavior unchanged.
A zero-slot `admit` configuration is unusable. Validate it before starting the listener or preheat and report the budget/model failure as a configuration error. `off` and `observe` retain their existing tiny-host behavior. After valid startup, bootstrap status remains explicitly unwired until the registry/policy are installed.
## 4. Admission and lifecycle
Reuse the current spawn transaction:
1. Reserve in the one shared `ProcessRegistry`.
2. Call the policy with `committedProcessCount`, including this reservation.
3. In `admit`, if this count exceeds `maxConcurrentChildren`, throw `AcpChildCapacityExceededError` before `spawn()`; the existing catch cancels the reservation.
4. Otherwise spawn with the existing memory arguments and attach to the reservation.
5. Retain current exit, failure, abort and teardown release behavior.
Before reservation the rule is `committed < limit`; after reservation it is `committed <= limit`. For a six-slot limit, five committed processes permit one more, while six cause rejection of a seventh. Keep reserve/check/spawn synchronous with no new `await` between them. Two starts racing for the last slot must not both pass.
No session-level admission is added when the child can be reused. Existing session limits still apply. Primary preheat consumes a slot while retained; temporary workspace-control children and managed background starts consume slots too. Admission decisions do not inspect whether another child is active or idle in PR1.
During channel replacement, the old process remains committed until registry release. Full occupancy can therefore reject a replacement. Do not add a temporary extra slot, kill another workspace or retry a replacement in a loop. Preserve the initiating operation's existing drain/rollback outcome. A refused spawn itself starts no new child; this does not mean its whole request performed no earlier filesystem or lifecycle changes.
Keep registry cleanup semantics unchanged: a signal or closed stream is not itself a released slot, and tracked root/known ownership release is not proof that every possible descendant was observed or terminated. No descendant accounting rewrite is part of PR1.
## 5. Errors and ownership
Add one shared typed error in the existing bridge-error surface, exported through existing package entrypoints. Use stable wire code `acp_child_capacity_exhausted`, REST status 503, and the existing ACP error envelope with equivalent machine-readable code/status metadata. Include `maxConcurrentChildren` and `committedAcpChildren` at rejection; the committed value excludes the rejected reservation and includes tracked teardown. A bounded error snapshot is informational, not a reservation for a later retry.
For an ordinary REST rejection, put the code and counters in the existing JSON error body. For ACP, use `RPC.INTERNAL_ERROR` with `data.errorKind: 'acp_child_capacity_exhausted'`, `data.httpStatus: 503` and the same counters. The existing SDK keeps this payload under `DaemonHttpError.body.data`; the frontend recognizer must support both `body.code` and `body.data.errorKind`, without matching message text or rewriting transport normalization. For an already-started stream, preserve the existing terminal error framing and the capacity reason; do not attempt to change the HTTP status after headers were sent.
Do not send `Retry-After` for this error or imply a timed recovery guarantee. Do not globally change the behavior of unrelated 503, authentication, rate-limit or transport errors. Callers may retry after deliberate user action or a later independent operation; there is no automatic wait queue, immediate fallback or daemon capacity retry loop.
| Consumer / operation | Ownership | Required behavior |
| ------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Status and shared policy | Process-global | Report the same global limit/count for all managed workspace factories. |
| Workspace registration / stored history listing | Persisted-workspace scoped | No new capacity gate when no ACP starts; registration remains independent. |
| Primary preheat / legacy-primary endpoints | Legacy-primary | Count the child; preserve startup diagnostics and primary ownership. |
| Session creation, runtime ensure, workspace MCP control | Selected-runtime | Return the capacity cause for that runtime; never fall back to primary. |
| Live prompt/attach, restore, fork and recovery | Live-session-owner or selected persisted owner | Reuse a live child without extra count; enforce when the operation actually spawns. Preserve the resolved owner across awaits. |
| Conversations standalone creation | Standalone session's resolved runtime/owner | Preserve capacity as a reason through rollback; do not erase existing creation-outcome metadata. |
| Channel workers and scheduled execution | Their selected runtime/session owner | ACP starts share the gate; external workers themselves are not counted as ACP. Surface failure through existing task reporting without adding capacity retry loops. |
Preserve causes across `WorkspaceRuntimeInitializationError` and `StandaloneSessionSpawnError`. In known wrapper paths, detect and retain capacity after existing cleanup/outcome handling. Generation-closed, removed, untrusted, draining and uncertain-dispatch/rollback outcomes keep their existing priority; capacity must not turn an unsafe whole-request retry into a safe one. Do not recursively unwrap arbitrary unrelated exceptions or replace all wrapped failures with 503.
For runtime initialization, let a capacity cause reach the specific mapper before generic `runtime_initialization_failed` and its current `Retry-After: 5` response. For standalone creation, retain `standalone_creation_rolled_back`, the session ID and existing outcome fields; add a bounded `capacity` object containing the capacity code and counters only after `!dispatched` and persisted-session absence are verified. Recognize this nested cause in the same UI helper. Post-dispatch failures or failed absence checks retain the existing unknown-outcome/quarantine path. Do not bypass these checks merely because the nested factory error is a capacity error.
Only this verified capacity-related standalone rollback changes from the existing 500 response to 503 and omits `Retry-After`; ordinary rollback responses remain unchanged. Carry `capacity` through both REST body and RPC data. The UI helper also recognizes `body.capacity.code` and `body.data.capacity.code`, while preserving the outer rollback classification. Preserve the existing retryability/outcome meaning for deliberate retries; the new capacity UI branch stops automatic retry regardless of that flag.
Verify REST session creation/load/fork/recovery, runtime ensure, MCP preparation/mutation, Conversations, direct ACP dispatch, preheat and background entrypoints. A 503 alone is insufficient: assert the capacity code or preserved capacity reason and the owner-specific outcome.
## 6. Status and Web Shell
Keep `limits.memory.enforced: false`, explicitly meaning that the modeled child heap ceiling is not applied. Within `limits.memory.childHeap`, add `admissionEnforced: boolean`; it is true only for the fully wired managed `admit` path. Retain `mode`, `maxConcurrentChildren`, modeled `perChildCeilingMb` and `refusals`. Under `observe`, refusals remain hypothetical; under `admit`, they count actual admission rejections. A zero counter does not prove memory safety.
Add `runtime.memory.committedAcpChildren` from the shared registry. Keep `activeAcpChildren` and RSS/reporter coverage unchanged; they count live channels and exclude outstanding reservations and dying channels. Use null where registry-backed reporting is unavailable, and optional fields in the SDK for older daemons. Bootstrap has no constructed policy: keep `childHeap` null and do not invent a live zero count. Wire the getter through the same managed server/status construction used by the shared policy.
Use existing Web Shell error/banner and draft-session surfaces. Proposed Chinese copy:
> 已达到当前服务的并发容量上限,暂时无法启动此会话。请稍后重试,或取消本次操作。
Proposed English copy:
> The service has reached its concurrent process limit and cannot start this session. Try again later or cancel this operation.
Use “capacity limit”, not a claim that live RAM is exhausted or every workspace is busy. PR1 does not choose another workspace to close. Reuse the existing cancel/dismiss flow: stop local pending/reconnect attempts, preserve the draft and attachments for a new session, and preserve the selected session/history when restoration fails. Cancelling does not delete a registered workspace or persisted session. A request that has already failed is not a queued background creation.
Special-case the machine code before Web Shell's generic 503/session-load retry path. No repeated reconnect, automatic create fallback, or prompt replay on capacity rejection. Manual retry repeats only the appropriate operation after its existing side-effect/outcome checks. Session create, restore, first-message and command shortcuts must all show the same understandable reason; capability diagnostics may retain technical counts, but raw policy/heap identifiers should not enter the user flow.
The connection provider uses an explicit readable English capacity fallback because the primary provider sits outside App's language context; App action errors and MCP notices use the active UI language. The concrete reuse points are the action notice/ToastHost for creation, the connection error state for failed loading, and the MCP manager's existing inline management notice. Classify capacity before `actions.ts` emits its generic notice and marks the error `_alreadyDispatched`; changing only App's `reportError` would be suppressed. `DaemonSessionProvider` needs a capacity branch before its ordinary workspace-load 503 backoff, rather than treating capacity as a missing session. Normal first-prompt and inline `!shell` submissions already defer composer clearing; the no-session `/goal set` path currently accepts before asynchronous creation and needs the same deferred acceptance on failure. Preserve existing navigation semantics: selecting a different target may detach the old pane; cancellation does not promise transactional restoration of that previous attachment. Do not reuse `SessionRecoveryBanner`, which is for a connected session's transcript recovery.
## 7. Implementation footprint
| Area | Expected files / integration points |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Policy and spawn | `packages/acp-bridge/src/child-heap-policy.ts`, `spawnChannel.ts`, `bridgeErrors.ts`, public exports; reuse `process-registry.ts`. |
| CLI and wiring | `packages/cli/src/commands/serve.ts`, `serve/fast-path.ts`, `serve/types.ts`, `serve/run-qwen-serve.ts`, `serve/server.ts`, `serve/daemon-status.ts`. |
| Cause propagation | `serve/server/error-response.ts`, `serve/acp-http/dispatch.ts`, `serve/workspace-runtime-coordinator.ts`, `serve/conversations/standalone-session-service.ts` and their existing typed errors. |
| SDK and UI | `packages/sdk-typescript/src/daemon/types.ts`; Web Shell `daemon/session/httpErrors.ts` or the existing code helper, `daemon/session/DaemonSessionProvider.tsx`, `daemon/session/actions.ts`, `App.tsx`, MCP management notice and existing locale resources. The SDK already retains error bodies; change transport implementation only if a test exposes a gap. |
| Documentation | This bilingual design; daemon configuration/operations, REST integration and serve protocol docs; reconcile the earlier fixed-heap design's delivery boundary. |
| Tests | Collocated policy/factory, parser/wiring, error mapping, runtime/standalone and UI tests; focused managed-daemon E2E. |
Do not rename unrelated modules, add a new scheduling abstraction or revise the budget arithmetic as cleanup. Exact affected files should follow verified call sites during implementation; avoid mirroring every error at every route when a shared typed path already works.
## 8. Validation and acceptance
1. Budget values remain unchanged, including explicit overrides, cgroup/host capping, six slots on 7265 MiB, the 25-slot ceiling and zero-slot cases. Both parsers accept `admit`; defaults and legacy modes remain unchanged.
2. At limit N, at most N managed ACP children/reservations are admitted across different factories; an N+1 request returns the exact capacity cause. Failure/cancellation releases reservations. A terminating tracked child still consumes capacity until registry release.
3. Accepted child command lines are unchanged across `observe` and `admit`, including inherited Node settings. No test substitutes the modeled 544 MiB value for the actual argument. No claim of an aggregate heap/RSS bound is made.
4. Registration and existing-child session reuse work at capacity. Reuse still obeys existing session limits. A genuinely new child is denied from REST, ACP, runtime/MCP, restoration and managed background paths; owner, auth and draining semantics are preserved.
5. Wrapper/outcome tests cover successful rollback, uncertain side effects, generation changes and preheat failures. Capacity is not hidden by `runtime_initialization_failed` or a generic reconnect error, and does not override a more important rollback outcome.
6. Web Shell shows the capacity reason once, ends the spinner/retry loop and preserves new-session text/attachments or restoration state. Cancel has no delete side effect. A deliberate retry after a slot is released works without duplicated prompts or worktrees.
7. Status accurately separates count admission from modeled heap values, handles bootstrap/direct-embed/older-server fields, and reads the same shared registry used at spawn.
8. Run build, typecheck and focused package tests for implementation. Run the E2E baseline first with global `qwen`, then the bundled implementation in isolated directories with a deterministic local provider. Model load/heap calibration is not needed to demonstrate slot accounting and error UX.
The detailed E2E plan is stored at `.qwen/e2e-tests/daemon-acp-capacity-admission-pr1.md`. Validation combines the global CLI baseline, focused package tests and isolated bundled-daemon process checks. Results and remaining verification limits are recorded in that plan. Earlier heap calibration runs do not validate this admission implementation.
## 9. Delivery sequence and limits
PR1 ships the opt-in count limit and error/cancel UX together. PR2 can try eligible idle-child reclamation when admission fails, reusing current close protections; the initial reclamation design remains the agreed single eligibility check without a new locking mechanism. PR3 adds an explicit choice of workspace to close when no eligible idle candidate exists. Neither follow-up is a prerequisite for PR1's refusal behavior.
Keep the default `observe` during initial rollout; changing the default is a later product decision. The unchanged model can reduce concurrency substantially when opted in, and full occupancy can block restart/MCP operations even if measured free memory is ample. Existing children can still grow toward their legacy heap limits. This is a predictable process-count policy, not an OOM guarantee or a completed fixed-heap enforcement design.

View file

@ -0,0 +1,159 @@
# Daemon ACP 容量准入 — PR1
[English](daemon-acp-capacity-admission-pr1.md) | [简体中文](daemon-acp-capacity-admission-pr1.zh-CN.md)
## 1. 状态与范围
本设计提案日期为 2026-09-15,核对代码基线 `df864bea6a930d31faf8b35fc5e57bac98a25e20`。随同变更以显式开启模式实现,记录本轮讨论更新:复用现有初始化预算限制 ACP 进程启动,再用后续独立 PR 增加空闲回收和用户主动选择关闭。
PR1 提供显式开启的进程数量限制,以及可操作的容量不足提示。不调整子进程堆参数,不采样实时空闲内存决定准入,不回收空闲 workspace,不主动中断已有工作,也不实现 workspace 选择器。现有回收器继续独立运行。用户要求的简化适用于后续回收机制;PR1 复用已有同步预留流程,不新增锁、队列或分布式准入协议。
建议实现 PR 标题:`feat(serve): add budget-based ACP child admission`。
三个阶段的提案统一由 [#11907](https://github.com/QwenLM/qwen-code/issues/11907) 跟踪。本文细化其中第一阶段,空闲回收和用户主动选择关闭仍由独立后续 PR 推进。
2026-09-15 读取的 [#8182](https://github.com/QwenLM/qwen-code/issues/8182) 仍为 open,剩余范围包括固定堆限额执行及代表性证据;[#11386](https://github.com/QwenLM/qwen-code/issues/11386) 已关闭。PR1 属于相关后续工作,使用 `Related to #8182` 并引用 #11386 作为背景,不使用关闭关键词。仅限制数量不能完成 #8182。
## 2. 当前行为与缺口
| 现有组件 | 已核实行为 | PR1 如何使用 |
| --------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `resolveDaemonMemoryBudget` | 初始化时解析宿主/cgroup 总内存、配置/有效预算、root 预留和子进程池。 | 原样复用数值及取整规则。 |
| `createChildHeapPolicy` | `off` 不发布分区;默认 `observe` 计算固定分区并统计假设拒绝。 | 增加按同一计算上限限制进程数量的模式。 |
| `ProcessRegistry` | 统计已挂接子进程和未完成的启动预留;正在清理的被跟踪对象保留到登记表释放。 | 使用 `committedProcessCount`,不使用已注册 workspace 数或活跃 channel 数。 |
| `createSpawnChannelFactory` | 已在检查策略前预留,当前忽略拒绝结果;同步异常会取消预留。 | 在原有流程中、`spawn()` 前执行拒绝。 |
| `runQwenServe` | primary、启动时的 secondary 和动态 workspace 工厂共享登记表和策略。 | 所有工厂使用一个 daemon 全局上限。 |
| Session/channel 回收器 | 已关闭符合条件的遗弃会话和空 ACP channel;保持连接的会话不等同于遗弃会话。 | 保留现状;按容量主动回收属于 PR2。 |
| REST/ACP/UI 错误 | 容量原因可能被 runtime/standalone 包装吞掉;部分 Web Shell 503 加载失败进入重连重试。 | 保留明确原因,停止容量错误的自动重试。 |
一个 ACP 可以保留多个 session。在已有子进程中新开 session 不一定增加进程名额;相反,预热、MCP 操作、channel 替换、恢复和后台工作也可能在不注册新 workspace 时启动子进程。因此准入应放在公共子进程启动边界。
### 2.1 预算计算
之前实测识别为 7265 MiB 的宿主,在默认配置下:
| 数量 | 计算 | 结果 |
| ---------------- | -------------------------------------------- | -------- |
| 有效预算 | `floor(7265 * 0.5)` | 3632 MiB |
| Root 预留 | `clamp(floor(3632 * 0.1), 256, 1024)` | 363 MiB |
| 子进程池 | `3632 - 363` | 3269 MiB |
| 模型名额 | `min(floor(3269 / 512), 25)` | 6 |
| 模型单子进程限额 | `min(floor(3269 / 6), legacyChildCeilingMb)` | 544 MiB |
完整复用现有实现,包括显式 `--memory-budget-mb`、宿主/cgroup 截断、小内存宿主处理及旧限额校验。不要在路由或前端重写公式。如果无法形成满足 512 MiB 模型下限的分区,现有模型返回零名额和 null 限额。
PR1 只把名额数用于实际准入。544 MiB 仍是模型值,真实子进程堆参数继续走 `getAcpMemoryArgs()`。六个名额代表配置的并发上限,不证明任意六个负载能放进内存。初始化的 `availableMemoryMb` 是解析后的宿主/容器总量,不是实时空闲内存。既有堆校准仍服务于后续固定堆变更,但不是这个独立的数量限制模式的前置条件。
## 3. 配置与兼容性
在现有 `--child-heap-mode` 和 `ChildHeapMode` 中增加 `admit`;不新增环境变量、独立预算公式、按 workspace 平分的分母或单独的最大进程数配置。
| 模式 | 计算/发布分区 | 拒绝超额 ACP 启动 | 应用模型堆限额 |
| ------------------------- | ------------- | ----------------- | -------------- |
| `off` | 否 | 否 | 否 |
| `observe`(默认不变) | 是 | 否 | 否 |
| `admit`(新增、显式开启) | 是 | 是 | 否 |
实现后的使用示例:`qwen serve --child-heap-mode admit`。现有 `--memory-budget-mb` 覆盖项通过原有公式影响名额,也保留其既有 journal 预算效果,并不是专用并发开关。预算和模式在 daemon 生命周期内固定,修改需要重启。PR1 不增加 `enforce`,也不暗示 `admit` 强制执行堆/RSS 预算。
同时更新快速解析、完整 CLI choices/help、编程接口选项、策略类型和 SDK 状态镜像。保留 `off`/`observe` 行为、旧子进程参数、workspace 注册上限及 workspace/全局 session 上限。未显式开启的升级不得引入容量拒绝。
支持托管的 `runQwenServe` 启动链路。对注入 bridge 或未接入准入的直接嵌入工厂,拒绝 `admit`,不能接受一个实际无效的配置。公开的准入工厂必须显式接收共享登记表。缺少托管准入接线的直接 `createServeApp` 调用拒绝 `admit`;托管 server 构建应传递真实的共享登记表/策略引用,不能把仅用于状态的快照回调当作限制已生效的证明。普通直接嵌入、IDE 及独立 `qwen --acp` 行为保持不变。
零名额的 `admit` 配置无法工作。应在启动监听器或预热前校验,以配置错误报告预算/模型失败。`off`、`observe` 保留既有小内存宿主行为。有效配置启动后,在登记表/策略安装前,bootstrap 状态仍明确表示尚未接线。
## 4. 准入与生命周期
复用现有启动事务:
1. 在唯一共享的 `ProcessRegistry` 中预留。
2. 使用包含本次预留的 `committedProcessCount` 调用策略。
3. `admit` 模式下,若该数值大于 `maxConcurrentChildren`,在 `spawn()` 前抛出 `AcpChildCapacityExceededError`,由现有 catch 取消预留。
4. 否则使用原有内存参数启动,并挂接到预留。
5. 保留现有退出、失败、abort 和清理释放行为。
预留前规则是 `committed < limit`,预留后规则是 `committed <= limit`。六名额上限下,已占五个允许再开一个,已占六个则拒绝第七个。保持 reserve/check/spawn 同步,中间不增加 `await`。两个启动同时争抢最后一个名额时不能都通过。
复用已有子进程时不增加 session 级的准入检查,原有 session 上限仍然生效。Primary 预热进程在驻留期间占名额;临时 workspace 控制进程及托管后台启动也占名额。PR1 做准入决策时不检查其他子进程是否忙碌或空闲。
Channel 替换时旧进程持续占名额,直到登记表释放,因此满容量可能拒绝替换。不临时多给一个名额、不关闭其他 workspace,也不循环重试替换。保留发起操作既有的 drain/回滚结果。拒绝 spawn 本身不会创建新子进程,但不能据此声称整个请求此前没有文件系统或生命周期变更。
登记表清理语义保持不变:发出信号或关闭流本身不等于名额释放;被跟踪根进程/已知所有权范围释放,也不证明所有可能的后代都被观测或终止。PR1 不重写后代进程统计。
## 5. 错误与所有权
在既有 bridge 错误入口增加一个共享类型,通过现有 package 入口导出。固定 wire code 为 `acp_child_capacity_exhausted`,REST 为 503,ACP 使用现有错误 envelope 携带等价机器码/状态元数据。拒绝时附带 `maxConcurrentChildren` 和 `committedAcpChildren`;后者不含已拒绝的预留,但包括被跟踪的清理中进程。这只是有界的错误时刻快照,不为稍后的重试保留名额。
普通 REST 拒绝把 code 和计数放进现有 JSON 错误 body。ACP 使用 `RPC.INTERNAL_ERROR`,携带 `data.errorKind: 'acp_child_capacity_exhausted'`、`data.httpStatus: 503` 及同样的计数。现有 SDK 将此 payload 保留在 `DaemonHttpError.body.data`,前端识别器必须同时支持 `body.code` 和 `body.data.errorKind`,不匹配消息文本,也不重写传输归一化。已经开始响应的流沿用现有终止错误格式并保留容量原因,不能在发送 headers 后再修改 HTTP 状态。
此错误不发送 `Retry-After`,也不承诺经过固定时间就恢复。不要全局改变其他 503、鉴权、限流或传输错误的处理。调用方可以在用户主动操作或之后一次独立操作时重试;不增加自动等待队列、立即降级或 daemon 容量重试循环。
| 消费者/操作 | 所有权 | 要求 |
| ------------------------------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 状态与共享策略 | Process-global | 所有托管 workspace 工厂报告相同的全局上限/计数。 |
| Workspace 注册/持久化历史列表 | Persisted-workspace scoped | 不启动 ACP 时不新增容量门槛;注册保持独立。 |
| Primary 预热/legacy-primary 接口 | Legacy-primary | 子进程计数,保留启动诊断及 primary 所有权。 |
| Session 创建、runtime ensure、workspace MCP 控制 | Selected-runtime | 返回该 runtime 的容量原因,不回退 primary。 |
| 活跃 prompt/attach、恢复、fork 和故障恢复 | Live-session-owner 或选定的持久化 owner | 复用活跃子进程不增加计数;实际启动时执行准入,跨 await 保留解析好的 owner。 |
| Conversations standalone 创建 | Standalone session 的解析后 runtime/owner | 回滚后保留容量原因,不抹去原有创建结果元数据。 |
| Channel worker 与定时执行 | 各自选定的 runtime/session owner | ACP 启动共享准入;外部 worker 本身不算 ACP。使用现有任务报告渠道呈现失败,不增加容量重试循环。 |
保留 `WorkspaceRuntimeInitializationError` 和 `StandaloneSessionSpawnError` 内的原因。在已知包装路径中,完成既有清理/结果处理后识别并保留容量原因。Generation 关闭、removed、untrusted、draining 及执行/回滚不确定等结果保留原有优先级;不能因为容量错误就把不安全的整请求重试标成安全。不递归拆解无关异常,也不把所有包装异常替换为 503。
Runtime 初始化的容量原因应在通用 `runtime_initialization_failed` 及其现有 `Retry-After: 5` 响应前进入专用映射。Standalone 创建保留 `standalone_creation_rolled_back`、session ID 和既有结果字段,仅在确认 `!dispatched` 且持久化 session 不存在后,附加包含容量 code 与计数的有界 `capacity` 对象。相同的 UI helper 也识别该嵌套原因。派发后失败或不存在校验失败继续走既有 unknown-outcome/quarantine 路径,不能因为内部工厂错误是容量不足就绕过这些校验。
仅这一类已确认与容量有关的 standalone 回滚由现有 500 改为 503,并去掉 `Retry-After`;普通回滚响应不变。REST body 和 RPC data 都传递 `capacity`。UI helper 同时识别 `body.capacity.code`、`body.data.capacity.code`,保留外层回滚分类。已有 retryability/结果对于用户主动重试的含义不变;新增容量 UI 分支无论该标志如何都停止自动重试。
验证 REST session 创建/load/fork/恢复、runtime ensure、MCP 准备/变更、Conversations、直接 ACP dispatch、预热及后台入口。只断言 503 不够,还应断言容量 code 或保留的容量原因,以及 owner 对应的结果语义。
## 6. 状态与 Web Shell
保留 `limits.memory.enforced: false`,明确表示没有应用模型子进程堆限额。在 `limits.memory.childHeap` 内增加 `admissionEnforced: boolean`,仅完整接线的托管 `admit` 路径为 true。保留 `mode`、`maxConcurrentChildren`、模型 `perChildCeilingMb` 和 `refusals`。`observe` 的 refusals 仍是假设拒绝,`admit` 则记录真实准入拒绝;零计数不证明内存安全。
增加 `runtime.memory.committedAcpChildren`,直接来自共享登记表。`activeAcpChildren` 及 RSS/上报覆盖语义保持不变:它们统计活跃 channel,不包含未完成预留及 dying channel。无法从登记表报告时使用 null,SDK 对新增字段使用 optional 以兼容旧 daemon。Bootstrap 尚未构造策略,保持 `childHeap` 为 null,不虚构一个活跃数量零值。沿用共享策略的托管 server/status 链路接入 getter。
复用 Web Shell 既有错误/banner 和草稿会话界面。建议中文提示:
> 已达到当前服务的并发容量上限,暂时无法启动此会话。请稍后重试,或取消本次操作。
建议英文提示:
> The service has reached its concurrent process limit and cannot start this session. Try again later or cancel this operation.
使用“容量上限”,不能声称实时内存已经耗尽或所有 workspace 都忙碌。PR1 不选择其他 workspace 关闭。复用现有取消/关闭提示流程:停止本地等待/重连尝试,新会话保留草稿和附件,恢复失败保留选中会话/历史。取消不删除已注册 workspace 或持久化会话。已经失败的请求不是后台排队中的创建。
在 Web Shell 通用 503/session-load 重试路径之前识别机器码。容量拒绝不重复重连、不自动降级为 create,也不自动重放 prompt。用户主动重试只能在原有副作用/结果检查后重复对应操作。Session 创建、恢复、首条消息及命令快捷入口都应展示一致、可理解的原因;诊断可保留技术计数,但原始策略/堆标识不进入用户操作界面。
主连接 Provider 位于 App 语言上下文之外,因此连接错误使用明确可读的英文容量兜底;App 操作错误和 MCP 提示使用当前界面语言。具体复用创建操作的 action notice/ToastHost、加载失败的 connection error 状态,以及 MCP manager 已有的 inline management notice。容量分类应发生在 `actions.ts` 发出通用 notice 并标记 `_alreadyDispatched` 之前,仅修改 App 的 `reportError` 会被抑制。`DaemonSessionProvider` 需要在普通 workspace-load 503 退避之前增加容量分支,不能将其当作 session 不存在。普通首条 prompt 和 inline `!shell` 已延迟清空输入;无 session 的 `/goal set` 当前在异步创建前就接受输入,失败时需要同样延迟接受。保留现有导航语义:切换目标可能 detach 旧面板,取消不承诺事务式恢复之前的 attachment。不要复用 `SessionRecoveryBanner`,它用于已连接会话的 transcript 恢复。
## 7. 实现落点
| 区域 | 预计文件/接入点 |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 策略与启动 | `packages/acp-bridge/src/child-heap-policy.ts`、`spawnChannel.ts`、`bridgeErrors.ts` 及公开导出,复用 `process-registry.ts`。 |
| CLI 与接线 | `packages/cli/src/commands/serve.ts`、`serve/fast-path.ts`、`serve/types.ts`、`serve/run-qwen-serve.ts`、`serve/server.ts`、`serve/daemon-status.ts`。 |
| 原因传播 | `serve/server/error-response.ts`、`serve/acp-http/dispatch.ts`、`serve/workspace-runtime-coordinator.ts`、`serve/conversations/standalone-session-service.ts` 及既有错误类型。 |
| SDK 与 UI | `packages/sdk-typescript/src/daemon/types.ts`;Web Shell `daemon/session/httpErrors.ts` 或现有 code helper、`daemon/session/DaemonSessionProvider.tsx`、`daemon/session/actions.ts`、`App.tsx`、MCP 管理提示及现有语言资源。SDK 已保留错误 body,仅在测试证明存在缺口时才修改传输实现。 |
| 文档 | 本双语设计;daemon 配置/运维、REST 集成和 serve 协议文档;同步较早固定堆设计的交付边界。 |
| 测试 | 同目录策略/工厂、解析/接线、错误映射、runtime/standalone 和 UI 测试;针对性的托管 daemon E2E。 |
不顺手重命名无关模块、不增加新调度抽象,也不修改预算公式。实现时根据核实的调用点确定最终文件范围;公共类型链路已能处理时,不在每个路由重复一份错误映射。
## 8. 验证与验收
1. 预算值保持一致,包括显式覆盖、cgroup/宿主截断、7265 MiB 对应六个名额、25 名额封顶及零名额场景。两个解析器均接受 `admit`,默认和旧模式不变。
2. 上限 N 时,不同工厂合计最多准入 N 个托管 ACP/预留,N+1 返回准确容量原因。失败/取消释放预留,正在清理的被跟踪子进程直到登记表释放前仍占名额。
3. `observe` 与 `admit` 下获准子进程的命令行保持一致,包括继承的 Node 配置。测试不能用模型 544 MiB 替代实际参数,也不声称总堆/RSS 有界。
4. 满容量时注册及复用已有子进程仍能工作,复用仍遵循原有 session 上限。REST、ACP、runtime/MCP、恢复及托管后台路径实际需要新子进程时被拒绝,保留 owner、鉴权和 draining 语义。
5. 包装/结果测试覆盖回滚成功、副作用不确定、generation 变化和预热失败。容量原因不被 `runtime_initialization_failed` 或通用重连错误吞掉,也不覆盖更重要的回滚结果。
6. Web Shell 展示一次容量原因,结束 spinner/重试循环,保留新会话文本/附件或恢复状态。取消不产生删除副作用。释放名额后用户主动重试成功,不重复 prompt 或 worktree。
7. 状态准确区分数量准入与模型堆值,正确处理 bootstrap/直接嵌入/旧服务字段,读取启动准入使用的同一登记表。
8. 实现阶段执行 build、typecheck 和对应 package 的定向测试。E2E 先使用全局 `qwen` 跑基线,再使用打包后的实现,在隔离目录和确定性的本地 provider 上验证。证明名额统计及错误交互不需要再跑模型负载/堆校准。
详细 E2E 计划存放在 `.qwen/e2e-tests/daemon-acp-capacity-admission-pr1.md`。验证结合全局 CLI 基线、package 定向测试及隔离的打包 daemon 进程检查;结果和未验证边界记录在测试计划中。此前的堆校准不能作为本次准入实现的验证依据。
## 9. 交付顺序与限制
PR1 同时交付显式开启的数量限制与错误/取消交互。PR2 在准入失败时尝试回收符合条件的空闲子进程,复用现有关闭保护;初版回收仍采用讨论确定的单次资格检查,不新增锁机制。PR3 在没有可回收空闲对象时,增加用户明确选择关闭哪个 workspace 的操作。后续两个 PR 都不是 PR1 拒绝行为的前置依赖。
初期发布继续默认 `observe`,修改默认值留作后续产品决策。显式开启后,原有模型可能明显降低并发;即便实测空闲内存充足,满名额也可能阻止 restart/MCP 操作。已有子进程仍可向旧堆限额增长。这是可预测的进程数量策略,不是 OOM 保证,也不是完整的固定堆强制执行设计。

View file

@ -0,0 +1,131 @@
# PR2: Reclaim idle ACP processes when admission is full
[English](daemon-acp-idle-reclamation-pr2.md) | [简体中文](daemon-acp-idle-reclamation-pr2.zh-CN.md)
## 1. Status and delivery boundary
Implemented locally with the verification below completed, 2026-09-15. Source baseline: `88347ccca394902262915dde880499976e596cd7`, the merged [PR1 #11911](https://github.com/QwenLM/qwen-code/pull/11911). Delivery tracker: [#11907](https://github.com/QwenLM/qwen-code/issues/11907). This change includes the PR2 implementation and collocated regression tests. Runtime evidence is recorded in the local E2E report.
PR1 delivered opt-in, daemon-wide ACP process-count admission and recognizable capacity errors. PR2 adds on-demand reclamation of eligible idle ACP children. PR3 remains the user-directed choice to close a workspace runtime or cancel when automatic reclamation cannot help. None of these stages applies the modeled per-child heap ceiling or completes [#8182](https://github.com/QwenLM/qwen-code/issues/8182).
**First version: automatically reclaim only an ACP channel with zero live sessions and no pending work.** Keep the existing session reaper responsible for expired abandoned sessions; do not advance its timeout or close a connected conversation just because it has not generated recently. Once that reaper has closed every session, a remaining warm channel can become a candidate.
This deliberately narrows the proposal's broader option of closing idle-but-loaded sessions on demand. That option needs additional persistence, conditional-close and recovery semantics and is outside this PR2. An idle open conversation with an SSE subscriber is protected. So is a loaded session without a subscriber until the existing lifecycle closes it. This boundary should be reflected in the tracker when implementation is submitted; it must not be described as complete support for evicting loaded sessions.
## 2. Current behavior and the gap
| Existing behavior | Source | Implication |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Every managed factory reserves before deciding and counts attached, reserved and unreleased terminating children. | `packages/acp-bridge/src/spawnChannel.ts`, `process-registry.ts` | Keep this as the only capacity authority. Registered workspace count and live-channel count are insufficient. |
| A full `admit` factory throws `AcpChildCapacityExceededError` before OS spawn. | `spawnChannel.ts` | Reclamation must happen before continuing the original spawn, without replaying an HTTP request or prompt. |
| An empty channel normally closes immediately when `channelIdleTimeoutMs` is zero; a positive timeout or completed preheat can keep it warm. | `bridge.ts`: `startIdleTimer`, `resolvedChannelIdleTimeoutMs`, `preheat` | A warm but unused ACP can consume the last slot despite having no session or work. |
| The session reaper defaults to a 30-minute idle threshold and a 60-second scan interval; it checks local work, subscribers, restore state and child conditional-close protections. | `bridge.ts`: `entryIsAutoCloseCandidate`, `startSessionReaper` | Reuse its existing behavior; PR2 does not become a second session-close policy. |
| Runtime lifecycle state covers starting, stopping, session work, restores, unsettled starts, MCP/control operations and reservations. | `bridge.ts`: `getWorkspaceRuntimeLifecycleSnapshot`, `hasNoChannelWork` | `activePromptCount === 0` or `bridge.activeWork === false` alone cannot establish eligibility. |
| Workspace removal also drains services, disposes the coordinator, shuts down the bridge and may remove registration. | `run-qwen-serve.ts`: `disposeRuntime`; `routes/workspace-management.ts` | Do not call removal, bridge shutdown, kill-all or the workspace DELETE route for reclamation. |
| Channel exit and subsequent ensure already support a new child and an increased runtime epoch; coordinator capability state becomes stale across epochs. | `bridge.ts`: `ensureChannel` and exit cleanup; `workspace-runtime-coordinator.ts`: `status`, `ensure` | Retain the workspace runtime and bridge; release only the physical child. |
This PR saves no registered-workspace host objects. Its primary benefit is ending idle ACP warm retention when another workspace needs capacity. If every occupied slot hosts a live session, PR2 still returns PR1's capacity error. It is not a guarantee that every cold request can succeed.
## 3. Trigger and end-to-end flow
Enable reclamation only for the existing fully wired managed `--child-heap-mode admit` path. Keep `off`, `observe`, single-child consumers and injected/unmanaged bridges unchanged. Add no environment variable, CLI mode, public route or idle-timeout setting.
1. An operation needs a new ACP. Existing-child reuse takes its normal fast path and does not trigger reclamation.
2. The factory reserves a slot and evaluates the existing policy. If admitted, spawn normally.
3. If capacity rejects this attempt, cancel that attempt's reservation before any asynchronous reclamation. Otherwise waiting requests themselves would inflate occupancy while trying to free it.
4. Ask the daemon's shared reclamation helper to try once. The helper first checks whether another operation has already released capacity; if so, there is no reason to select a victim.
5. Read eligible runtimes, exclude the requester, sort by last use, and try the oldest candidate. One requested spawn may attempt at most one victim. No candidate, a changed candidate, a failed close or an occupied reclamation target ends this attempt without trying a cascade of other workspaces.
6. The victim bridge validates its current channel/epoch and idle state, synchronously marks that channel dying through the existing path, and awaits normal tracked teardown. Retain its workspace runtime and bridge.
7. After teardown, recheck the request's cancellation and the shared registry. If no slot is available, return the existing capacity error. Do not infer release from a cold lifecycle state, an exited root PID alone, a boolean callback result or a decremented workspace count.
8. Only with available capacity, obtain a fresh reservation and run the same policy again before spawn. A competing request may win the released slot. In that case, refuse normally; do not evict a second victim. There is at most one reclamation opportunity and one subsequent admission attempt per factory invocation.
Do not retry the outer create/load/MCP operation. The same pending factory call resumes before its child exists, so accepted prompts, worktree creation and rollback are not replayed. Generic transport failures, session limits, initialization errors and stale-owner errors do not trigger reclamation.
## 4. Candidate eligibility and ownership
Reuse the activity aggregation currently used by workspace removal, extracting only the shared read logic into a small serve helper without changing removal semantics. Keep the bridge's detailed final guard in the bridge, where its private channel state is authoritative.
| Gate | First-version rule |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ownership | Managed, registered, active, trusted workspace runtime whose factory uses the same daemon process registry. Select by runtime identity/workspace ID, never by a guessed cwd alias. |
| Requester | Exclude the requesting runtime, including replacement attempts while an older child is retiring. Never await that runtime's own pending spawn while servicing its factory callback. |
| Primary and internal runtimes | The primary workspace may qualify under the same checks; being non-removable is not a resource pin. Exclude internal Conversations, scratch/standalone and other special runtime lifecycles from the initial candidate set. Their starts still use admission and may request reclamation of an eligible registered workspace. |
| Sessions | `sessionCount === 0`. Any loaded session blocks selection, including an idle attached session, a running/queued prompt, permission/user-answer wait, background task, or a session with unknown child-work reporting. |
| Bridge state | A current live channel with no starting/stopping overlap, no session spawn/restore or unsettled cleanup, no runtime operation reservations, no workspace control/MCP discovery/authentication and no shutdown/quarantine/condemned-channel state. |
| Daemon work | No pending session starts, ACP connections, memory tasks, channel workers, voice activity, or queued/running coordinator management work. Missing observation hooks or unsupported reclaim capability make the runtime ineligible; missing data is not zero. |
| Warm retention | A completed preheat's `keepAliveUntil` and a positive channel idle timeout are soft retention, and capacity demand may end them early. Pending preheat and initialization remain protected by their work reservations. |
| Identity | The selected runtime remains active and its channel/epoch matches the candidate. A removed, draining, trust-reconfigured or replaced runtime is skipped. |
Keep daemon-owned terminal processes, registrations, saved history, files, configuration, schedulers and service objects intact. Scheduled future work is not a reason to destroy those services; it may need to ensure a new ACP later and goes through the same admission policy. Running work reflected by the activity gates blocks reclamation. Do not claim that unrelated terminal processes or aggregate workspace RSS are released by killing the ACP.
These rules intentionally prioritize protecting sessions over maximum reclamation. They avoid needing a new persistence-in-flight signal or a new contract for closing a session whose only state is in memory. The existing session reaper and its conditional-close behavior remain unchanged.
A known untrusted requesting workspace cannot reclaim another workspace's child. Internal requesters not yet published in the registry retain the shared callback. During replacement, an older child retiring in the requesting workspace still counts until registry release; this overlap may reclaim a different eligible warm child and make its next use cold. The initial request also pays the victim teardown latency within its existing startup budget.
## 5. LRU and timing
Use last actual use of the current ACP channel, not registration age or the time a status page was polled. Add one internal `lastUsedAt` timestamp to the current channel's bookkeeping, initialized when that channel becomes usable and touched when real session activity or workspace control/preheat work is accepted or finishes. Carry a private candidate token containing channel identity/epoch and this timestamp. Do not add a public status field solely for sorting.
The existing `bridge.lastActivityAt` is not a complete substitute: it can remain null on a preheated channel and does not cover workspace-management calls. Preserve its existing external semantics instead of quietly broadening an idle-detection API. Periodic status reads, diagnostics and heartbeat reports that only repeat unchanged state must not keep the new recency timestamp warm; substantive work transitions may update it.
Order eligible candidates by ascending `lastUsedAt`, then by workspace ID for deterministic ties. This is an in-memory ordering, not a distributed or perfectly monotonic clock guarantee. No new grace duration is required for an empty, otherwise safe channel: the existing retention was an optimization, and explicit capacity demand takes precedence. Existing session idle timeout and prompt-settled grace remain unchanged.
## 6. Minimal implementation seams
| Component | Proposed change and consumers |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `spawnChannel.ts` | Add one optional internal-use reclamation callback to the factory options. Invoke only on typed capacity rejection in `admit`; cancel the rejected reservation before awaiting, then perform at most one fresh reserve/decide. Every new path retains cancellation and cleanup handling. |
| Serve reclamation helper | A small daemon-owned helper reads the workspace registry and shared activity facts, excludes the requester and unsupported runtimes, sorts candidates and invokes one bridge reclaim operation. It reads the shared registry count and the policy's published limit, with no independent capacity model, second registry, persistent queue or HTTP retries. |
| `bridgeTypes.ts` and `bridge.ts` | Add optional candidate-read and idle-channel-reclaim capabilities to the bridge interface; lightweight fakes and injected bridges may omit them. Recheck current identity and existing no-work guards, reject condemned/stopping channels, then reuse tracked termination. Maintain the internal recency timestamp and clear soft keepalive state only for the retired channel. |
| `run-qwen-serve.ts` and `server.ts` | Bind requester identity through factory closures for primary, secondary/dynamic and managed internal creation paths. Supply one shared helper with the same policy/registry. Before registry/activity wiring is ready, the callback cannot reclaim and the normal capacity result applies. Standalone `createApp` construction with managed factories must follow the same contract. |
| `routes/workspace-management.ts` and coordinator | Reuse the existing activity sources and preserve their removal behavior. Do not relax `hasActiveWork()` to make sessions eligible and do not call `dispose()`. The conservative zero-session rule needs no new management-only lifecycle API. |
| Tests and docs | Extend collocated factory, bridge idle-lifecycle and serve wiring tests. Add deterministic daemon/API recovery tests; update the PR1 operational description and tracker at submission. Keep the two language versions synchronized. |
Callback absence preserves PR1 behavior, so direct consumers cannot accidentally start evicting other runtimes. Read every newly added option at an exercised call site; tests must cover dynamic factories and managed server construction, not just startup's primary factory.
## 7. Failure, cancellation and concurrency
Retain the agreed simple eligibility check and existing lifecycle protections. No new atomic eviction transaction, daemon-wide drain protocol or eviction lock is proposed. The bridge's final identity/no-work check and marking the chosen channel dying must have no intervening await; this preserves the existing channel-close invariant without inventing a new protocol.
A request canceled before reclamation selects nothing. If cancellation arrives after victim termination has begun, allow that owned cleanup to finish, but do not spawn the requested child or attempt another victim. Bound waiting by the existing factory startup abort/deadline and termination budgets; a timeout must not permit a late factory result to escape cleanup. Reclamation latency is part of the original startup timeout, not a new unbounded allowance. While the factory awaits reclamation, its optional startup context supplies the capacity error for the bridge deadline; the bridge aborts with that error and retains late-result cleanup. Clear the override as soon as reclamation settles, so unrelated later startup timeouts keep their usual error.
A candidate selected by two callers is not force-closed twice: the existing dying/identity checks make the second attempt skip. No freed slot belongs to a particular requester. Final reserve/decide remains authoritative even if another request has already started. If the shared registry still reports no free slot after teardown, including tracked descendants, keep reporting capacity exhaustion. A teardown error after the registry releases the slot does not itself prevent admission; never manually decrement registry counts or run a broad kill command.
Candidate observation and concurrent external activity are not a transactional snapshot of the whole workspace. The first version relies on existing guards and protects all live sessions; it does not promise an atomic stop of every external actor. A candidate changing during selection may reduce the success rate. Capacity failure is preferable to force-closing it or endlessly searching.
Preserve REST `503`, `acp_child_capacity_exhausted` and ACP error metadata when reclamation cannot make capacity. Preserve PR1's draft retention and no-automatic-retry UX. Successful reclamation requires no workspace-deletion event or confirmation dialog. Existing capability staleness and cold-start status remain the recovery signals; the workspace chooser belongs to PR3.
The existing `refusals` counter records rejected spawn admission attempts. With reclamation, the first rejected attempt can be followed by a successful retry, so it must not be described as the count of user-visible failed operations. Document this distinction; do not add a second public metrics model for this PR. Use the existing diagnostic channel for reclamation attempts and failures; normal channel-exit logging records teardown. No per-poll or per-skip logging is added.
## 8. Recovery behavior
Reclamation retains workspace ID, aliases, registration intent, trust, runtime storage roots, environment bindings and the bridge/coordinator objects. The selected channel exits through normal cleanup. A subsequent ensure or saved-session load starts a new channel and negotiates a newer runtime epoch; stale MCP/Skills state is rebuilt through existing preparation.
There is no live session to migrate in the selected child. Saved conversations remain loadable through their resolved owner runtime. Cold restore may be slower and may still fail admission if every slot is now occupied. Do not fall back to primary, create a replacement conversation on 404, or claim that unsaved in-memory session state was migrated. Background scheduler and worker service lifecycle must remain unchanged by this child-only operation.
## 9. Validation and acceptance
Use the `e2e-testing` workflow with an isolated daemon, deterministic local provider and test-owned child processes. Record the global CLI baseline before implementation; if it already includes later changes, record its exact version rather than inventing an expected failure. The local test plan is `.qwen/e2e-tests/daemon-acp-idle-reclamation-pr2.md`; baseline and implementation results are recorded alongside it.
1. At one slot, warm empty A blocks cold B on the PR1 baseline. With PR2, the same operation succeeds after A's tracked child is released; registration and saved history do not change.
2. With several empty warm candidates, the least recently used one is reclaimed. MCP/control and preheat activity update ordering; repeated status polling does not. Ties are deterministic. Stop after one victim.
3. A loaded session protects A even when no prompt is running. Separately cover running/queued prompts, permission/user-answer waits, background work and disconnected-but-not-yet-reaped sessions. No session-close or kill is sent by PR2.
4. Empty-channel activity gates protect concurrent create/load, queued coordinator work, MCP discovery/OAuth, voice/channel services, ACP connections and memory jobs. Unknown ownership or missing hooks skips reclamation. Completed soft keepalive alone does not block it.
5. Admitted but not yet attached starts plus attached/unreleased children stay within the limit across concurrent starts, delayed root/descendant release, teardown failure, requester cancellation and a competing winner. Reserve-before-decide can transiently count a rejected reservation above the limit; it must be canceled before any await and must never spawn. No reservation leaks, second victim or outer request replay occur.
6. Primary and secondary eligible workspaces work; special/internal candidates remain excluded. Primary, secondary, dynamically created and managed internal requesters all use the shared callback and preserve their owner-specific errors. Cold bootstrap without a candidate remains a normal capacity failure.
7. Re-enter reclaimed A and load saved history: verify workspace identity, environment/storage roots, epoch advancement, MCP/Skills refresh and no duplicate prompt. Terminal/scheduler/service objects and registration survive.
8. Default/off/observe behavior, heap arguments, session limits, zero-slot validation and PR1 error/rollback/UI behavior remain unchanged. Run build, typecheck, focused tests and real daemon/process-tree checks for implementation; do not substitute mocked release for Linux/Windows process-tree evidence.
### Executed verification (2026-09-15)
Full build, typecheck, bundle and focused lint passed. The ACP focused suite passed 80 tests, serve lifecycle/routes passed 696 tests, and standalone server wiring passed 4 tests. Coverage includes the single-victim policy, no spawn before release, cancellation/failure/competition outcomes, candidate identity and activity protection, soft retention, MCP completion recency and status polling without recency updates.
Five real-daemon groups passed 52 assertions on macOS with Node 22: empty warm reclamation, old-PID exit, cold recovery of the original workspace with an increased epoch, history preservation and original session-ID loading, loaded idle session and ACP-connection protection, LRU ordering, dynamic requesters, preserved registration identity and unchanged heap arguments. All test-owned processes exited. The detailed report is `.qwen/e2e-tests/pr2-idle-reclamation/verification-report.md`.
Linux/Windows, real delayed descendant exit and fault injection, real model conversations and all business-service combinations were not exercised. Cancellation and competition use focused unit-test evidence; those tests do not replace process-tree verification on the deployment target. These tests do not prove actual RAM safety or performance improvement.
## 10. Decisions and remaining work
The implemented scope is: reclaim empty warm channels, preserve every live session, use one victim and one admission retry, add no public API or tuning parameter, and leave user choice to PR3. The tracker currently describes a wider idle-session option; reconcile that delivery claim when submitting implementation rather than silently marking the wider option complete.
Implementation must verify the extracted activity helper has all existing sources at every managed construction path, and that every genuine workspace-control entry point updates recency. Tests must establish release-before-spawn and restore correctness on the built artifact. This design makes no measured performance, RAM-safety or cross-platform validation claim.

View file

@ -0,0 +1,131 @@
# PR2:准入名额已满时回收空闲 ACP 进程
[English](daemon-acp-idle-reclamation-pr2.md) | [简体中文](daemon-acp-idle-reclamation-pr2.zh-CN.md)
## 1. 状态与交付边界
已在本地实现并完成下述验证,日期为 2026-09-15。源码基线:`88347ccca394902262915dde880499976e596cd7`,即已合并的 [PR1 #11911](https://github.com/QwenLM/qwen-code/pull/11911)。交付跟踪:[#11907](https://github.com/QwenLM/qwen-code/issues/11907)。本次变更包含 PR2 实现与相邻回归测试,运行证据记录在本地 E2E 报告中。
PR1 已交付可选启用的 daemon 全局 ACP 进程数量准入,以及可识别的容量错误。PR2 增加按需回收符合条件的空闲 ACP 子进程。PR3 仍负责自动回收无能为力时,让用户选择关闭 workspace runtime 或取消。上述阶段都不施加建模的单 child 堆上限,也不完成 [#8182](https://github.com/QwenLM/qwen-code/issues/8182)。
**首版只自动回收 live session 数量为零且没有待处理工作的 ACP channel。** 过期且无人使用的 session 继续交给现有 session reaper;不提前其超时,也不因为很久没有生成内容就关闭仍连接着的聊天。现有 reaper 关闭所有 session 后,仍保温的 channel 才能成为候选。
这有意收窄了 proposal 中“按需关闭已加载但空闲的 session”这一更宽的选项。后者需要补充持久化、条件关闭和恢复语义,不属于本 PR2。有 SSE 订阅的空闲聊天受到保护;没有订阅但仍已加载的 session,也要等现有生命周期将其关闭。提交实现时应在 tracker 中同步这个边界,不能宣称已经完整支持驱逐已加载的 session。
## 2. 当前行为与缺口
| 现有行为 | 源码 | 对方案的影响 |
| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 所有受管理 factory 都先预留再判断,计入已绑定、已预留及退出中尚未释放的 child。 | `packages/acp-bridge/src/spawnChannel.ts`、`process-registry.ts` | 保留它作为唯一容量依据,不能用注册 workspace 数或 live channel 数替代。 |
| 满额时,`admit` factory 在 OS spawn 前抛出 `AcpChildCapacityExceededError`。 | `spawnChannel.ts` | 回收后继续原来的 spawn,不能重放 HTTP 请求或 prompt。 |
| `channelIdleTimeoutMs` 为零时,空 channel 通常立即关闭;正值超时或已完成的 preheat 可以继续保温。 | `bridge.ts`:`startIdleTimer`、`resolvedChannelIdleTimeoutMs`、`preheat` | 一个没有 session 和工作的保温 ACP 仍可能占着最后的名额。 |
| session reaper 默认空闲阈值为 30 分钟、扫描间隔为 60 秒;检查本地工作、订阅、恢复状态和 child 条件关闭保护。 | `bridge.ts`:`entryIsAutoCloseCandidate`、`startSessionReaper` | 沿用已有行为;PR2 不再实现另一套 session 关闭策略。 |
| runtime 生命周期覆盖启动、停止、session 工作、恢复、未结算启动、MCP/control 操作及预留。 | `bridge.ts`:`getWorkspaceRuntimeLifecycleSnapshot`、`hasNoChannelWork` | 仅凭 `activePromptCount === 0` 或 `bridge.activeWork === false` 无法认定可回收。 |
| workspace 移除还会 drain 服务、dispose coordinator、shutdown bridge,并可能移除注册。 | `run-qwen-serve.ts`:`disposeRuntime`;`routes/workspace-management.ts` | 回收不能调用 removal、bridge shutdown、kill-all 或 workspace DELETE 路由。 |
| channel 退出后再次 ensure 已支持新 child 和递增 runtime epoch;coordinator 能将旧 epoch 的能力状态标记为 stale。 | `bridge.ts`:`ensureChannel` 与退出清理;`workspace-runtime-coordinator.ts`:`status`、`ensure` | 保留 workspace runtime 和 bridge,仅释放物理 child。 |
本 PR 不回收注册 workspace 的宿主对象,主要收益是在另一个 workspace 需要名额时提前结束空闲 ACP 的保温。如果所有占用名额的进程都有 live session,PR2 仍返回 PR1 的容量错误;不能保证所有冷启动请求都成功。
## 3. 触发条件与完整流程
只在已经完整接线的受管理 `--child-heap-mode admit` 路径启用回收。`off`、`observe`、单 child 消费方及注入或非托管 bridge 保持原样。不增加环境变量、CLI 模式、公开路由或空闲超时设置。
1. 操作需要一个新的 ACP。已有 child 的复用继续走原来的快速路径,不触发回收。
2. factory 预留名额并执行已有策略;准入通过就正常 spawn。
3. 容量拒绝本次尝试时,在等待任何异步回收前取消该次 reservation,否则等待启动的请求本身会继续虚增占用。
4. 请求 daemon 共享的回收 helper 尝试一次。helper 先检查是否已有其他操作释放名额;如果已有容量,就不选择牺牲者。
5. 读取符合条件的 runtime,排除请求方,按最近使用时间排序,并尝试最旧的候选。每次请求 spawn 最多尝试一个候选。没有候选、候选状态变化、关闭失败或目标正被回收时,本次尝试结束,不连锁尝试其他 workspace。
6. 候选 bridge 核验当前 channel/epoch 和空闲状态,通过现有路径同步标记该 channel 为 dying,并等待正常的被跟踪退出流程。保留 workspace runtime 和 bridge。
7. 退出后重新检查请求取消状态和共享 registry。没有名额就返回原容量错误。不能根据 cold 生命周期、仅根 PID 退出、回调返回布尔值或 workspace 数减少,就推断名额已经释放。
8. 确认存在容量后,重新 reserve,再执行同一准入策略,最后才 spawn。其他请求可能先抢到刚释放的名额;此时正常拒绝,不再回收第二个候选。每次 factory 调用最多有一次回收机会和一次后续准入尝试。
不重试外层 create/load/MCP 操作。同一个待完成的 factory 调用在 child 尚不存在时继续执行,因此不会重放已接受的 prompt、worktree 创建或 rollback。普通传输失败、session 数量上限、初始化错误及过期 owner 错误都不触发回收。
## 4. 候选条件与归属
复用 workspace removal 当前的活动聚合,只把共享读取逻辑提取到一个小的 serve helper,不改变 removal 语义。bridge 的详细最终判断仍留在 bridge 内,由其私有 channel 状态提供权威依据。
| 条件 | 首版规则 |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 归属 | 受管理、已注册、active 且 trusted 的 workspace runtime,其 factory 使用同一 daemon 进程 registry。按 runtime 身份/workspace ID 选择,不根据猜测的 cwd alias 选择。 |
| 请求方 | 排除发起请求的 runtime,包括旧 child 正在退出时的替换启动。处理 factory 回调时,不能等待该 runtime 自己的待完成 spawn。 |
| primary 与内部 runtime | primary workspace 满足相同条件时可以回收;不可删除不代表资源必须常驻。首版候选不包含内部 Conversations、scratch/standalone 及其他特殊生命周期 runtime。它们启动时仍受准入控制,也可以请求回收一个符合条件的已注册 workspace。 |
| session | `sessionCount === 0`。任何已加载 session 都阻止选择,包括空闲但仍连接的 session、运行或排队中的 prompt、permission/用户回答等待、后台任务,以及 child 工作报告未知的 session。 |
| bridge 状态 | 存在当前 live channel,没有 starting/stopping 重叠,没有 session spawn/restore、未结算清理、runtime 操作预留、workspace control/MCP discovery/authentication,也不处于 shutdown/quarantine/condemned-channel 状态。 |
| daemon 工作 | 没有 pending session start、ACP connection、memory task、channel worker、voice 活动,以及排队或运行中的 coordinator 管理工作。缺少观测 hook 或不支持回收能力时,不进入候选;缺失数据不是零。 |
| 保温 | 已完成 preheat 的 `keepAliveUntil` 和正值 channel idle timeout 属于软保温,容量请求可以提前结束它们。待完成的 preheat 和初始化仍由工作 reservation 保护。 |
| 身份 | 被选 runtime 仍处于 active,且 channel/epoch 与候选一致。已 removed、draining、trust-reconfigured 或被替换的 runtime 直接跳过。 |
保留 daemon 持有的 terminal 进程、注册、历史、文件、配置、调度器和服务对象。未来计划执行的任务不应导致这些服务被销毁;其执行时可能需要重新 ensure ACP,并经过同一准入策略。活动条件能观测到的运行工作会阻止回收。不能声称杀掉 ACP 同时释放了无关 terminal 进程或整个 workspace 的 RSS。
这些规则有意优先保护 session,而不是尽可能多地回收。这样无需新增“持久化进行中”信号,也无需定义只有内存状态的 session 如何安全关闭。现有 session reaper 及条件关闭行为保持不变。
已注册且不可信的请求 workspace 不能回收其他 workspace 的子进程。尚未发布到 registry 的内部请求方保留共享回调。请求 workspace 替换进程时,旧进程在 registry 释放前仍占名额;这一重叠可能回收其他符合条件的预热子进程,使其下次使用需要冷启动。初始请求也会在现有启动预算内承担候选进程退出的延迟。
## 5. LRU 与时间策略
使用当前 ACP channel 最近一次实际使用时间,不使用注册时间或状态页面轮询时间。在当前 channel 的内部状态中增加一个 `lastUsedAt`,channel 可用时初始化,在实际 session 活动或 workspace control/preheat 工作接受、完成时更新。内部候选 token 包含 channel 身份/epoch 和该时间戳。不为排序单独增加公开 status 字段。
已有 `bridge.lastActivityAt` 不能完整替代:仅预热的 channel 可能一直为 null,且它不覆盖 workspace 管理调用。保留其已有对外语义,避免悄悄扩展 idle-detection API。周期状态读取、诊断和仅重复未变化状态的 heartbeat 不应让新时间戳一直保持新鲜;实质工作状态变化可以更新时间。
候选按 `lastUsedAt` 升序排列,相同时按 workspace ID 稳定排序。这是内存内排序,不承诺分布式时钟或完美单调时钟。空 channel 满足其他安全条件时不再额外等待新的宽限时间:原有保温是优化,明确的容量需求优先。已有 session idle timeout 和 prompt-settled grace 不变。
## 6. 最小实现接点
| 组件 | 建议变更与消费方 |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `spawnChannel.ts` | factory options 增加一个可选、供内部接线使用的回收回调。仅在 `admit` 的类型化容量拒绝上调用;等待前取消被拒 reservation,随后最多重新 reserve/decide 一次。所有新路径继续处理取消和清理。 |
| serve 回收 helper | 一个 daemon 持有的小 helper 读取 workspace registry 和共享活动事实,排除请求方及不支持的 runtime,排序并调用一次 bridge 回收。它读取共享 registry 计数和策略公布的上限,不维护独立容量模型、第二个 registry、持久队列或 HTTP 重试。 |
| `bridgeTypes.ts` 与 `bridge.ts` | bridge 接口增加可选的候选读取和空闲 channel 回收能力,轻量 fake 与注入 bridge 可以省略。重新核对当前身份和已有无工作条件,拒绝 condemned/stopping channel,然后复用被跟踪的退出。维护内部使用时间,并且只清理被退出 channel 的软保温状态。 |
| `run-qwen-serve.ts` 与 `server.ts` | primary、secondary/dynamic 及受管理内部创建路径通过 factory 闭包绑定请求方身份。共享 helper 使用同一 policy/registry。在 registry/活动接线完成前,回调无法回收,沿用容量错误。独立 `createApp` 构建受管理 factory 时也必须遵循同一契约。 |
| `routes/workspace-management.ts` 与 coordinator | 复用现有活动数据源,保留 removal 行为。不为让 session 可回收而放宽 `hasActiveWork()`,也不调用 `dispose()`。保守的零 session 规则不需要另增只表示管理工作的生命周期 API。 |
| 测试与文档 | 扩展相邻 factory、bridge 空闲生命周期和 serve 接线测试。增加确定性 daemon/API 恢复测试;提交时更新 PR1 运维描述和 tracker;保持双语设计一致。 |
回调缺省保留 PR1 行为,直接消费方不会意外开始回收其他 runtime。每个新增选项都必须在实际调用方被读取且得到测试,覆盖动态 factory 和受管理 server 构建,不能只测试启动时的 primary factory。
## 7. 失败、取消与并发
沿用讨论中约定的简单候选检查和已有生命周期保护,不引入新的原子驱逐事务、daemon 全局 drain 协议或驱逐锁。bridge 最终身份/无工作检查与标记所选 channel 为 dying 之间不能插入 await;这是保持原有 channel 关闭约束,不是另建协议。
回收前取消的请求不选择候选。如果取消发生在候选退出已经开始之后,让该自有资源清理完成,但不再启动请求的新 child,也不尝试其他候选。等待受已有 factory startup abort/deadline 与终止预算限制;超时不能让迟到 factory 结果逃脱清理。回收耗时计入原启动超时,不新增无限等待额度。factory 等待回收期间,通过可选启动上下文为 bridge 的 deadline 提供容量错误;bridge 使用该错误取消启动,并保留迟到结果清理。回收结束后立即清除该覆盖,后续无关启动超时保持原有错误。
两个调用方同时选择同一候选时,已有 dying/identity 检查让后者跳过,不能强制关闭两次。刚释放的名额不归任何请求专有;其他请求已启动时,最终 reserve/decide 仍是唯一依据。退出后共享 registry 仍无空闲名额(包括后代仍被跟踪)时继续报告容量不足。registry 已释放名额后的退出错误本身不阻止准入;不能手工减 registry 计数或运行宽泛 kill 命令。
候选观测与外部并发活动不是整个 workspace 的事务快照。首版依赖已有保护,并排除所有 live session;不承诺原子停止每个外部参与者。候选在选择过程中变化可能降低回收成功率,应返回容量失败,而不是强制关闭或无限搜索。
回收无法提供容量时,保留 REST `503`、`acp_child_capacity_exhausted` 和 ACP 错误元数据。保留 PR1 草稿和不自动重试的 UX。回收成功不需要 workspace 删除事件或确认弹窗。继续用已有能力 stale 状态和冷启动状态表达恢复,workspace 选择器留在 PR3。
已有 `refusals` 统计的是被拒绝的 spawn 准入尝试。加入回收后,第一次被拒后可能重试成功,因此不能把它描述成用户最终失败的操作数。记录这个区别,本 PR 不另增一套公开指标。回收尝试与失败使用现有诊断通道,正常 channel-exit 日志记录退出;不增加每次轮询或跳过候选的日志。
## 8. 恢复行为
回收保留 workspace ID、alias、注册意图、trust、runtime 存储根目录、环境绑定和 bridge/coordinator 对象。所选 channel 通过正常清理退出。后续 ensure 或已保存 session 的 load 启动新 channel,协商更高的 runtime epoch;过期 MCP/Skills 状态通过已有准备流程重建。
所选 child 没有需要迁移的 live session。已保存会话继续通过其解析后的 owner runtime 加载。冷恢复可能更慢;如果此时所有名额又被占用,仍可能被准入拒绝。不能回退 primary、在 404 时创建替代会话,也不能宣称迁移了未持久化的内存 session 状态。这个仅针对 child 的操作不改变后台调度器和 worker 服务生命周期。
## 9. 验证与验收
按 `e2e-testing` 工作流,使用隔离 daemon、确定性本地 provider 和测试自有子进程。实现前记录全局 CLI 基线;若已包含后续变更,应记录准确版本,而不是编造预期失败。详细本地计划位于 `.qwen/e2e-tests/daemon-acp-idle-reclamation-pr2.md`,基线和实现验证结果记录在其旁。
1. 单名额下,保温空 A 在 PR1 基线中阻止冷 B。PR2 中同一操作应在 A 被跟踪 child 真正释放后成功,注册和已保存历史不变。
2. 多个保温空候选时回收最久未用者。MCP/control 与 preheat 活动更新顺序,反复查询状态不更新;同时间顺序稳定,只尝试一个候选。
3. A 只要有已加载 session,即使没有运行中的 prompt 也受到保护。分别覆盖运行/排队 prompt、permission/用户回答等待、后台工作,以及断开但尚未被 reaper 关闭的 session。PR2 不发送 session-close 或 kill。
4. 空 channel 的活动条件保护并发 create/load、coordinator 排队工作、MCP discovery/OAuth、voice/channel 服务、ACP 连接和 memory job。归属未知或缺失 hook 时跳过;仅已完成的软保温不阻止回收。
5. 并发启动、根/后代延迟退出、退出失败、请求取消及竞争者先获名额时,已获准但尚未绑定的启动加已绑定/尚未释放的 child 不超过上限。先 reserve 再 decide 会让被拒 reservation 短暂使计数超过上限,但必须在任何 await 前取消,且绝不能 spawn。不泄漏 reservation,不尝试第二个候选,也不重放外层请求。
6. 符合条件的 primary 和 secondary 均可回收;特殊/内部候选保持排除。primary、secondary、动态创建及受管理内部请求方均使用共享回调,并保留各自 owner 错误。启动早期没有候选时仍是正常容量失败。
7. 再进入已回收 A 并加载历史,核验 workspace 身份、环境/存储根目录、epoch 递增、MCP/Skills 刷新和无重复 prompt。terminal、scheduler、服务对象及注册均保留。
8. 默认/off/observe 行为、堆参数、session 上限、零名额验证及 PR1 错误/rollback/UI 行为不变。实现后执行 build、typecheck、定向测试及真实 daemon/进程树检查;不能以 mock 释放替代 Linux/Windows 进程树证据。
### 已执行的验证(2026-09-15)
完整 build、typecheck、bundle、定向 lint 通过。ACP 定向测试 80 项、serve 生命周期/路由测试 696 项、独立 server 接线测试 4 项通过。测试覆盖单候选策略、释放前不 spawn、取消/失败/竞争结果、候选身份与活动保护、软保温、MCP 完成更新时间和状态轮询不更新时间。
macOS、Node 22 上 5 组真实 daemon 测试的 52 项断言通过:空保温回收、旧 PID 退出、原 workspace 冷恢复和 epoch 递增、历史保留与原 session ID 加载、已加载空闲会话及 ACP connection 保护、LRU 顺序、动态注册请求方、注册身份及堆参数不变。测试自有进程全部退出,详细报告位于 `.qwen/e2e-tests/pr2-idle-reclamation/verification-report.md`。
未执行 Linux/Windows 验证、真实后代延迟退出与故障注入、真实模型对话及所有业务服务组合。进程取消/竞争等依赖定向单测证据;这些单测不能替代部署目标上的进程树验证。测试不证明实际 RAM 安全或性能收益。
## 10. 决策与剩余工作
已实现的范围为:回收空的保温 channel,保护所有 live session,只尝试一个候选并重试一次准入,不增加公开 API 或调参项,用户选择留在 PR3。Tracker 当前包含更宽的空闲 session 选项;提交实现时应同步交付范围,而不是悄悄把更宽的选项标记为完成。
实现时必须核验提取出的活动 helper 在各个受管理构建路径都有完整数据源,以及所有实质 workspace-control 入口都更新时间。测试必须通过构建产物证明先释放再启动和恢复正确。本文没有给出已测量的性能收益、RAM 安全保证或跨平台验证结论。

View file

@ -0,0 +1,83 @@
# Daemon extension Skill catalog
[English](daemon-extension-skill-catalog.md) | [简体中文](daemon-extension-skill-catalog.zh-CN.md)
This implements stage 2 of #11274. Previously the daemon-local workspace Skill
provider supplied an empty active-extension list, so its first response omitted
installed extension Skills when no child snapshot existed.
Use an unbound `ExtensionManager` for the selected workspace to load installed
extensions through the existing consistent store reader. Supply active
extensions to `SkillManager`, preserving project > user > extension > bundled
precedence. Append inactive extension Skills as management entries with the
existing `inactive_extension` status, retaining their identity and metadata.
Resolve settings and extension Skill defaults/overrides with the existing
parsers. A settings opt-in does not enable an inactive parent extension.
Resolve each extension's Skill defaults from its own manifest, with workspace
Skill overrides from the same consistent store snapshot used to load it. Cache
those booleans by the loaded extension object and normalized Skill name. This
avoids selecting the wrong owner through a colliding ID; it does not create a
new identity namespace or change store policy. Explicit opt-ins and hard
settings disablements retain their existing precedence.
Resolve localized extension names with the existing language setting and locale
helpers on every response, without changing the daemon process language or
rebuilding the directory cache when only the language changes.
Keep the lightweight Config surface: do not construct a runtime Config, start a
child, initialize MCP, execute hooks, or install watchers. Honor safe mode,
disabled discovery levels and workspace trust; inert untrusted inventory must
not load workspace settings or extension runtime context. Directory failures
continue to return an uninitialized error status.
The implementation and collocated regressions live in the daemon-local provider.
Tests cover real manifests, active/inactive state, source collisions, persisted
Skill settings, safe/untrusted contexts and explicit cache invalidation. E2E
evidence uses an isolated home and a daemon with no child session.
The facade still prefers child snapshots in this stage. Replacing that source,
changing toggle/refresh semantics, adding configured-state fields and changing
Web Shell projections belong to later PRs. No public schema changes are needed.
Discovery-level disabling suppresses active extension Skills through
`SkillManager`; inactive extension management entries are still appended, as in
the child producer. Safe mode and untrusted contexts never load extensions.
An absent extensions root is an empty inventory; no extension store is created.
With a present root the read reconciles through the shared store: a missing or
drifted store is initialized in place under the store's exclusive lock,
rewriting `extension-store/state.json`, creating its `state.previous.json`
rollback copy, and rewriting the legacy `extension-enablement.json` projection.
Cache hits reuse the loaded managers. A later rebuild still takes the lock and
may perform directory/permission maintenance even if policy is unchanged.
Unreadable roots and errors propagated by the shared store/loader return
`initialized: false` with explicit errors. That failure is deliberately
all-or-nothing: one bad extension
artifact (for example a dangling directory entry) fails the entire catalog,
project, user and bundled Skills included, until the artifact is repaired.
Individual artifact handling remains owned by the shared loader: malformed
manifests are skipped with its diagnostic, whereas a dangling extension entry
propagates an error. This stage does not add per-artifact diagnostics or
per-level degradation to the response, or change the loader's failure policy.
Existing facade caching, source preference and invalidation behavior remain
unchanged; the tracking issue assigns cache lifecycle and concurrency changes
to stage 4.
**Known later-stage items (recorded during review, deliberately not in this
stage):**
- The sibling `/workspace/extensions` route resolves its locale through
`loadSettings` without `skipLoadEnvironment`, so a workspace `.env`
`QWEN_CODE_LANG` can diverge from this provider's language resolution.
- The inactive-entry append and sort assembly duplicates the child producer's
(`acpAgent.ts`). Here a per-extension name set removes duplicate inactive
Skill names; it provides the same source separation without that string key.
- Extension mutations do not invalidate the config-catalog providers this stage
populates, so a committed install, update, enable/disable or uninstall can
leave stale extension Skill state on the skills config routes until an
unrelated skill mutation, a workspace removal, or a restart. The
invalidation wiring and `refreshCacheIfSourcesChanged` revalidation belong
to stage 4.
- The active-Skill `enabled` judgment mirrors `Config.isSkillEnabled` by hand.
- Shared store/API handling of duplicate extension IDs remains separate. This
provider preserves per-manifest defaults instead of querying an ambiguous
ID for its owner; it still consumes workspace overrides by the store's ID key.

View file

@ -0,0 +1,69 @@
# Daemon extension Skill 目录
[English](daemon-extension-skill-catalog.md) | [简体中文](daemon-extension-skill-catalog.zh-CN.md)
本设计实现 #11274 的第 2 阶段。此前 daemon 本地 workspace Skill provider
提供空的 active extension 列表,因此没有 child 快照时,首次响应缺少已安装
extension 的 Skill。
为选定 workspace 使用不绑定运行时 Config 的 `ExtensionManager`,通过现有
store 一致性读取加载扩展。将 active extension 交给 `SkillManager`,保留
project > user > extension > bundled 优先级。将 inactive extension 的 Skill
作为管理条目追加,使用既有 `inactive_extension` 状态,保留身份和元数据。
设置、extension Skill 默认值与覆盖沿用现有解析器。settings 显式启用不会启用
inactive 父扩展。
每个 extension 的 Skill 默认值来自其自身 manifest;workspace Skill 覆盖来自
加载扩展时使用的同一份 store 一致性快照。按已加载 extension 对象和归一化
Skill 名称缓存布尔结果,避免通过冲突 ID 再次选错 owner。这不新增身份命名空间,
也不修改 store 策略。显式启用与 settings 硬禁用保持原有优先级。
每次响应通过现有语言设置和 locale helper 解析本地化扩展名,不修改 daemon
进程语言,也不因单独的语言变化重建目录缓存。
保持轻量 Config 接口:不构造运行时 Config、不启动 child、不初始化 MCP、
不执行 hooks、不安装 watcher。遵守 safe mode、发现层级禁用及 workspace 信任
规则;未信任目录的静态盘点不加载 workspace 设置或 extension 运行时上下文。
目录读取失败继续返回未初始化错误状态。
实现与相邻回归测试位于 daemon 本地 provider。测试覆盖真实 manifest、
active/inactive 状态、来源冲突、持久化 Skill 设置、safe/untrusted 上下文及
显式缓存失效。E2E 使用隔离 home 和无 child 会话的 daemon。
本阶段 facade 仍优先使用 child 快照。替换来源、修改开关或刷新语义、增加配置
状态字段及修改 Web Shell 投影属于后续 PR。不修改公共响应 schema。
发现层级禁用通过 `SkillManager` 隐藏 active extension Skill;与 child producer
一致,仍追加 inactive extension 管理条目。safe mode 和未信任上下文不加载扩展。
extensions 根目录不存在代表空目录,不创建 extension store。根目录存在时,
通过共享 store 协调读取:缺失或发生漂移的 store 在独占锁内初始化,写入
`extension-store/state.json`、生成 `state.previous.json` 回滚副本,并写入旧格式
`extension-enablement.json` 投影。命中缓存时复用已加载 manager;后续重建仍会
获取锁,即使策略未变也可能维护目录与权限。
不可读根目录和共享 store/loader 抛出的错误返回 `initialized: false` 及明确
错误。该失败有意影响整份目录:例如一个悬空 extension 目录条目会使 project、
user 和 bundled Skill 一同不可用,直到制品被修复。单个制品的处理仍由共享
loader 决定:损坏 manifest 被跳过并记录其诊断,悬空条目则向外抛错。本阶段不
增加逐制品诊断或逐层降级,不修改 loader 失败策略。
现有 facade 缓存、来源优先级及失效行为不变;跟踪 issue 将缓存生命周期和并发
改造安排在第 4 阶段。
**评审中记录、明确不在本阶段实现的后续事项:**
- 相邻 `/workspace/extensions` 路由调用 `loadSettings` 时未设置
`skipLoadEnvironment`,workspace `.env` 的 `QWEN_CODE_LANG` 可能使其语言
解析与本 provider 不同。
- inactive 条目追加及排序与 child producer(`acpAgent.ts`)存在重复。这里按
extension 分别对 Skill 名去重,与字符串 `level:extensionName:name` key
提供相同的来源区分,并未使用该字符串 key。
- extension 变更没有失效本阶段填充的 config-catalog provider,因此安装、更新、
启停或卸载提交后,skills config 路由可能保留旧状态,直到无关 Skill 变更、
workspace 移除或重启。失效接线与 `refreshCacheIfSourcesChanged` 重新验证
属于第 4 阶段。
- active Skill 的 `enabled` 判断沿用 `Config.isSkillEnabled` 的规则,尚未共享
判定实现。
- 共享 store/API 的重复 extension ID 处理另行跟进。本 provider 从各自 manifest
保留默认值,不通过歧义 ID 查询 owner;workspace 覆盖仍使用 store 的 ID key。

View file

@ -18,6 +18,9 @@ REST integration surface also lack dedicated protocol sections.
- Publish an OpenAPI 3.1 definition for the 25 routes that the REST integration
guide presents as the supported integration surface.
- Keep every additional HTTP operation with a dedicated protocol section
discoverable from the human-readable index without adding it to the stable
OpenAPI contract.
- Give every operation its request parameters or body, success and common error
responses, capability requirement, ownership scope, stability, and
TypeScript SDK equivalent when one exists.
@ -40,13 +43,15 @@ REST integration surface also lack dedicated protocol sections.
## Public surface
The public REST reference is the 25-operation set already named by
The stable REST integration contract is the 25-operation set already named by
`docs/developers/rest-api-integration.md` and guarded by
`rest-integration-docs-contract.test.ts`. It covers discovery, session
lifecycle, prompting and SSE, permissions, and read-only workspace context.
The current daemon registers many additional routes for first-party UI and
conditional features; documenting those in OpenAPI would incorrectly turn an
implementation surface into a compatibility promise.
implementation surface into a compatibility promise. The human-readable index
may link routes that already have dedicated protocol sections, but must label
them as conditional or administrative surfaces outside the stable OpenAPI set.
## Artifacts
@ -78,6 +83,10 @@ OpenAPI artifact and provides one capability-grouped operation index. Detailed
behavior stays in `qwen-serve-protocol.md`; the reference links there instead
of copying thousands of lines of lifecycle prose.
The index has two explicit tiers: the 25-operation stable OpenAPI contract and
the additional documented v1 routes. The second tier exists for discovery and
does not promote first-party or conditional routes into the stable contract.
The integration guide remains task-oriented. It links to the reference and
keeps the runnable lifecycle walkthrough.
@ -111,6 +120,10 @@ renames and incomplete new reference entries without loading the full daemon.
Detailed field semantics still require ordinary review because the handlers do
not currently use a shared runtime schema system.
The same test compares the operation links in the human-readable index with
every operation that has a dedicated protocol heading. This keeps the broader
discovery index complete without expanding the OpenAPI operation set.
## Documentation-site handoff
After this change merges, `qwen-code-docs` will receive a separate change that
@ -141,6 +154,8 @@ repository owns presentation, translation, and deployment.
- All 25 integration operations appear exactly once in OpenAPI and have a
dedicated protocol section.
- Every additional operation with a dedicated protocol heading appears in the
human-readable index and remains clearly outside the stable OpenAPI set.
- The integration guide contains no "no dedicated reference section" caveat.
- Its minimal flow assigns `SID`, separates the SSE terminal, and shows how a
`permission_request` supplies `REQUEST_ID`.

View file

@ -16,6 +16,8 @@ SDK 方法,才能确认基本接口信息。REST 接入指南筛选出的接
## 目标
- 为 REST 接入指南作为受支持接入面列出的 25 个路由发布 OpenAPI 3.1 定义。
- 让每个已有独立协议章节的其他 HTTP 操作都能从人类可读索引中找到,同时不把它
加入稳定 OpenAPI 契约。
- 为每个操作写明请求参数或请求体、成功及通用错误响应、所需 capability、所有权
作用域、稳定性,以及存在时对应的 TypeScript SDK 方法。
- 补齐 8 个缺失的协议章节,并让 quickstart 命令可以直接运行。
@ -31,11 +33,12 @@ SDK 方法,才能确认基本接口信息。REST 接入指南筛选出的接
## 公共接口面
公共 REST Reference 就是 `docs/developers/rest-api-integration.md` 已列出且由
稳定 REST 接入契约就是 `docs/developers/rest-api-integration.md` 已列出且由
`rest-integration-docs-contract.test.ts` 守护的 25 个操作,覆盖发现、Session
生命周期、prompt 与 SSE、权限以及只读工作区上下文。当前 daemon 还为第一方 UI
和条件功能注册了大量其他路由;将它们写入 OpenAPI 会错误地把实现面变成兼容性
承诺。
承诺。人类可读索引可以链接已有独立协议章节的路由,但必须把它们标为稳定 OpenAPI
集合之外的条件或管理接口面。
## 产物
@ -63,6 +66,9 @@ SDK 方法,才能确认基本接口信息。REST 接入指南筛选出的接
能力提供一份操作索引。详细行为继续放在 `qwen-serve-protocol.md` 中,Reference
通过链接复用,而不复制数千行生命周期说明。
索引明确分为两层:25 个操作组成的稳定 OpenAPI 契约,以及其他已记录的 v1 路由。
第二层只用于发现,不会把第一方或条件路由提升为稳定契约。
接入指南继续面向任务,链接到 Reference,并保留可运行的完整生命周期示例。
### 补齐协议章节
@ -92,6 +98,9 @@ SDK 方法,才能确认基本接口信息。REST 接入指南筛选出的接
无需加载完整 daemon,就能发现路由重命名和不完整的新 Reference 条目。由于 handler
目前没有共用的运行时 schema 系统,详细字段语义仍需正常代码评审。
同一个测试还会比较人类可读索引中的操作链接与所有拥有独立协议标题的操作。这样
无需扩大 OpenAPI 操作集合,也能保证更广的发现索引完整。
## 文档站交接
本次变更合并后,`qwen-code-docs` 将通过独立变更把 OpenAPI 产物复制到静态站点、
@ -116,6 +125,8 @@ SDK 方法,才能确认基本接口信息。REST 接入指南筛选出的接
## 验收标准
- 25 个接入操作在 OpenAPI 中各出现一次,并各有独立协议章节。
- 每个拥有独立协议标题的其他操作都出现在人类可读索引中,并明确保持在稳定
OpenAPI 集合之外。
- 接入指南不再包含“尚无独立参考章节”的提示。
- 最小流程会赋值 `SID`、把 SSE 放在另一个终端,并说明如何从
`permission_request` 得到 `REQUEST_ID`。

View file

@ -1,8 +1,10 @@
# Daemon Skill Toggle
[English](daemon-skill-toggle.md) | [简体中文](daemon-skill-toggle.zh-CN.md)
## Goal
Expose workspace Skill settings writes through daemon REST and the TypeScript SDK, including immediate refresh of active ACP sessions without making the runtime Skill catalog an ownership source.
Expose workspace Skill settings writes through daemon REST and the TypeScript SDK, including immediate refresh of active ACP sessions. The runtime Skill catalog supplies identity for enable guards, but does not restrict which names may be persisted.
## Public contract
@ -16,9 +18,13 @@ The response contains the trimmed requested name, requested state, whether persi
## Semantics
The API changes workspace `skills.disabled` and `skills.enabled` by name without consulting the runtime Skill catalog. Enabling a default-disabled Skill writes an explicit opt-in; disabling it removes the opt-in and writes a hard workspace disable. Updating one target removes target duplicates and case variants without deleting orphan entries for unavailable Skills. A name may be disabled before installation, while hidden from user invocation, or while its Extension is inactive. Enabling removes an existing workspace disable or records an opt-in for an effective `skills.defaultDisabled` entry. An existing workspace `skills.enabled` declaration is preserved and normalized to the requested casing. With no existing workspace declaration and no effective `skills.defaultDisabled` entry, enable is a no-op (`changed: false`). A second identical request is also a no-op.
The API changes workspace `skills.disabled` and `skills.enabled` by name without requiring the name to be present in the runtime Skill catalog. Enabling a default-disabled Skill writes an explicit opt-in; disabling it removes the opt-in and writes a hard workspace disable. Updating one target removes target duplicates and case variants without deleting orphan entries for unavailable Skills. A name may be disabled before installation, while hidden from user invocation, or while its Extension is inactive. Enabling removes an existing workspace disable or records an opt-in for an effective `skills.defaultDisabled` entry. An existing workspace `skills.enabled` declaration is preserved and normalized to the requested casing. With no existing workspace declaration and no effective `skills.defaultDisabled` entry, enable is a no-op (`changed: false`). A second identical request is also a no-op.
A hard `skills.disabled` entry inherited from a higher scope remains authoritative for effective availability, but does not prevent workspace scope from recording or removing its own declaration. Workspace declarations otherwise participate in the usual `skills.disabled > skills.enabled > skills.defaultDisabled` resolution and can override higher-scope `skills.defaultDisabled` or `skills.enabled` entries. The route retains request-shape, authentication, client identity, workspace trust, and runtime-generation gates; none of those require a Skill catalog lookup.
Before an enable guard is evaluated, the persistence closure reads the workspace Skill status once for the whole single or batch request. A catalog entry at `level: 'extension'` with `extensionName` supplies both its registry name and its authored name after the known extension prefix; an entry at any other level has only its registry spelling. An unknown name, an uninitialized status, or a failed status read falls back to the previous punctuation-derived alias behavior. This fallback preserves legacy bare-name blocks for unavailable extension Skills. Daemon-local status omits extension Skills when no live ACP child has supplied a catalog, so those names remain on the fallback path until extension enumeration is available locally. Disables do not read the catalog.
Batch enables repeatedly reconsider refused names against the evolving final workspace lists until no list changes or every name has an outcome. This fixpoint makes results independent of request order, including multi-step legacy-alias chains, while retaining one settings write.
A hard `skills.disabled` entry inherited from a higher scope remains authoritative for effective availability, but does not prevent workspace scope from recording or removing its own declaration. Workspace declarations otherwise participate in the usual `skills.disabled > skills.enabled > skills.defaultDisabled` resolution and can override higher-scope `skills.defaultDisabled` or `skills.enabled` entries. The route retains request-shape, authentication, client identity, workspace trust, and runtime-generation gates. Catalog identity resolution stays inside the persistence closure and does not add route-level validation.
The workspace read-modify-write happens inside the daemon's per-workspace settings lock. A failed write stops before refresh and event publication.
@ -31,7 +37,7 @@ The workspace read-modify-write happens inside the daemon's per-workspace settin
## Activation flow
1. Validate the request name, authorization, workspace trust, client identity, and runtime generation.
2. Under the workspace settings lock, re-read every scope, compute the resulting workspace declaration changes, and commit them in at most one write.
2. Under the workspace settings lock, resolve enable identities from one catalog read, re-read every scope, compute the resulting workspace declaration changes, and commit them in at most one write.
3. If no declaration changed, return `changed: false` without cache invalidation, refresh, or event publication.
4. Otherwise, invalidate the daemon's cached skill status.
5. If an ACP child is live, invoke `qwen/control/workspace/skills/refresh`.
@ -56,6 +62,7 @@ An in-flight model request cannot be rewritten. Subsequent skill execution check
## Failure behavior
- Persistence failure: the HTTP request fails; no ACP refresh and no event.
- Skill-status read unavailable during enable: persistence uses the legacy punctuation-derived alias and continues; the request does not fail closed.
- No child after a declaration changed: persistence succeeds with `deferred`; the next child loads the setting at startup.
- No declaration change: the response reports `changed: false`; no refresh or event occurs. `activation` still reflects whether a child was live, but no activation work is needed.
- Per-session refresh failure: persistence remains committed; successful sessions stay refreshed and the response is `partial`.

View file

@ -0,0 +1,69 @@
# 守护进程 Skill 切换
[English](daemon-skill-toggle.md) | [简体中文](daemon-skill-toggle.zh-CN.md)
## 目标
通过守护进程 REST 和 TypeScript SDK 写入工作区 Skill 设置,并立即刷新活跃的 ACP 会话。运行时 Skill 目录为启用保护提供身份信息,但不限制哪些名称可以被持久化。
## 公共契约
- `POST /workspace/skills/:name/enable`
- `POST /workspaces/:workspace/skills/:name/enable`
- 请求体:`{ "enabled": boolean }`
- SDK:`DaemonClient.setWorkspaceSkillEnabled` 和 `WorkspaceDaemonClient.setWorkspaceSkillEnabled`
- 能力:`workspace_skill_settings_toggle`
响应包含去除首尾空白后的请求名称、请求状态、持久化是否发生变化、激活状态以及会话刷新数量。`activation` 独立于 `changed` 反映子进程存活状态和所需刷新:`applied` 表示子进程存活且所需刷新成功,`deferred` 表示在存活检查时没有子进程,或已变更的请求在所需刷新期间失去子进程或会话,`partial` 表示持久化提交后至少有一个其他所需刷新失败。因此,无操作请求的 `changed` 仍为 false,而 `activation` 可以是 `applied` 或 `deferred`。
## 语义
API 按名称修改工作区的 `skills.disabled` 和 `skills.enabled`,但不要求该名称存在于运行时 Skill 目录中。启用默认禁用的 Skill 会写入显式选择加入;禁用会移除选择加入并写入工作区硬禁用。更新一个目标会移除其重复项和大小写变体,但不会删除不可用 Skill 的孤立条目。名称可以在安装前、对用户调用隐藏时或所属 Extension 未激活时被禁用。启用会移除已有的工作区禁用,或为有效的 `skills.defaultDisabled` 条目记录选择加入。已有的工作区 `skills.enabled` 声明会被保留,并规范为请求中的大小写。若既没有工作区声明,也没有有效的 `skills.defaultDisabled` 条目,则启用是无操作(`changed: false`)。第二次相同请求同样是无操作。
在评估启用保护前,持久化闭包会为整个单项或批量请求读取一次工作区 Skill 状态。目录中带有 `level: 'extension'` 和 `extensionName` 的条目同时提供注册名称,以及去掉已知扩展前缀后的创作名称;其他层级的条目只有注册名称。未知名称、未初始化的状态或失败的状态读取都会回退到此前依据标点推导别名的行为。该回退保留了不可用扩展 Skill 的旧式裸名称阻止语义。没有活跃 ACP 子进程提供目录时,守护进程本地状态会省略扩展 Skill,因此在本地扩展枚举可用之前,这些名称仍走回退路径。禁用操作不读取目录。
批量启用会根据不断演进的最终工作区列表,反复重新考虑先前被拒绝的名称,直到列表不再变化或所有名称都有结果。该不动点计算使结果不受请求顺序影响,包括多步旧式别名链,同时仍只写入一次设置。
从更高作用域继承的硬 `skills.disabled` 条目仍决定有效可用性,但不会阻止工作区作用域记录或移除自己的声明。其他工作区声明按照通常的 `skills.disabled > skills.enabled > skills.defaultDisabled` 规则参与解析,并可覆盖更高作用域的 `skills.defaultDisabled` 或 `skills.enabled` 条目。路由保留请求形状、认证、客户端身份、工作区信任和运行时世代保护。目录身份解析位于持久化闭包内部,不增加路由级验证。
工作区的读取、修改和写入在守护进程的逐工作区设置锁内完成。写入失败会在刷新和事件发布前停止。
## Skill 可用性与 `disable-model-invocation`
`skills.disabled` 是运维人员的硬拒绝列表,会跨作用域按不区分大小写的并集合并。`skills.defaultDisabled` 提供可覆盖的默认值,`skills.enabled` 提供显式选择加入,优先级为 `disabled > enabled > defaultDisabled`。有效禁用会移除匹配的 Skill 斜杠命令和模型可见的 Skill 条目,执行时验证也会拒绝该 Skill。守护进程端点写入 `disabled` 和 `enabled` 的工作区成员。
`disable-model-invocation` 是 SKILL.md 元数据。它对模型隐藏 Skill,但保留用户直接调用。现有的托管 Skill ACP 操作编辑该元数据,本 API 有意不复用它。
## 激活流程
1. 验证请求名称、授权、工作区信任、客户端身份和运行时世代。
2. 在工作区设置锁内,通过一次目录读取解析启用身份,重新读取所有作用域,计算最终的工作区声明变更,并最多提交一次写入。
3. 若声明没有变化,返回 `changed: false`,且不进行缓存失效、刷新或事件发布。
4. 否则,使守护进程缓存的 Skill 状态失效。
5. 若 ACP 子进程存活,调用 `qwen/control/workspace/skills/refresh`。
6. 子进程重新加载工作区作用域设置,并刷新所有活跃会话,包括忙碌会话。
7. 每个会话重新加载自己的工作区设置,重建并推送 `available_commands_update`,并通知 SkillManager 使用方。
8. 为每个变更的 Skill 设置键发布现有的工作区 `settings_changed` 事件。
进行中的模型请求无法被改写。后续 Skill 执行检查、命令快照和模型上下文会读取新状态。
## 下游使用方
- 设置合并:系统默认、用户、工作区和系统列表以 `disabled > enabled > defaultDisabled` 的优先级形成有效的禁用名称集合。
- 工作区状态:ACP 和守护进程本地 Skill 映射公开禁用状态、禁用原因、锁定作用域和仅在为 false 时出现的 `userInvocable`。
- 斜杠命令:可用命令构建会移除禁用 Skill,并向守护进程客户端发送更新后的命令元数据。
- 模型上下文:SkillManager 变更监听器会刷新 Skill 工具描述和可用 Skill 上下文。
- 执行验证:Skill 工具在调用前重新读取禁用名称提供方,因此后续调用会被立即拒绝。
- 扩展状态:未激活的 Extension 仍使其 Skill 在运行时不可用,这与工作区设置是否记录这些名称无关。
- 守护进程缓存:持久化后会使缓存的活跃子进程 Skill 快照失效,防止后续 GET 请求重放过期状态。
- SDK 使用方:主工作区客户端和工作区限定客户端共享仅包含设置结果的响应契约。
- 事件:现有的 `settings_changed` 使用方会观察每个已提交的 `skills.disabled` 或 `skills.enabled` 值;不新增事件类型。
## 失败行为
- 持久化失败:HTTP 请求失败;不进行 ACP 刷新,也不发布事件。
- 启用期间无法读取 Skill 状态:持久化使用旧式的标点推导别名并继续;请求不会以失败关闭。
- 声明变更后没有子进程:持久化成功并返回 `deferred`;下一个子进程会在启动时加载设置。
- 声明没有变化:响应报告 `changed: false`;不进行刷新或事件发布。`activation` 仍反映子进程是否存活,但无需激活工作。
- 单会话刷新失败:持久化保持已提交;成功的会话保持已刷新,响应为 `partial`。
- 子进程传输竞态:若子进程在存活检查后消失,响应为 `deferred`;其他刷新失败报告为 `partial`。

View file

@ -0,0 +1,39 @@
# Durable User Resource Links
[English](daemon-user-resource-links.md) | [简体中文](daemon-user-resource-links.zh-CN.md)
## Status
Implemented in the proposed fix for [#11178](https://github.com/QwenLM/qwen-code/issues/11178), pending maintainer review and release. Verification results belong in the PR.
## Problem
ACP user prompts can contain `resource_link` content with a URI and display metadata. Live clients may seed attachment cards locally, but that does not persist them. The ACP session records only user text and daemon-native attachment references; URI links disappear before transcript replay. The SDK also discards these links during normalization.
[PR #11194](https://github.com/QwenLM/qwen-code/pull/11194) addresses SDK projection but assumes local-file model input is the durable user record. Original references must be saved separately from model-input expansion.
## Persistence and Replay
Keep the original ACP `resource_link` blocks in the existing user record's optional `systemPayload.resourceLinks` collection. Capture them from the public prompt, including attachment-only prompts and prompts whose model input is replaced by trusted context. Snapshot the references before passing them to the recorder.
The existing replay machine emits those saved blocks as `user_message_chunk` content, with the record's source, segment, prompt, and branch identity. Preserve URI, name, MIME type, size, description, title, annotations, and `_meta`, including valid nullable ACP fields. Native `attachmentReferences` retain their existing hydration contract.
Do not infer original links from `fileData`, generated `@uri` text, or model-expanded file contents. Those forms are lossy and may describe normal remote media rather than an ACP attachment. Older records without saved references cannot recover their missing cards.
## SDK Contract
Add the typed event `user.resource_link.delta` carrying `resourceLink: DaemonResourceLink`. The reference preserves the ACP content discriminator `type: 'resource_link'`. ACP update metadata remains in the event's separate `meta` field.
User transcript blocks expose optional `resourceLinks: DaemonResourceLink[]`. Deduplicate by URI within the owning user block, filling missing metadata on repeated echoes. Preserve different URIs with the same name and the same URI in separate turns. Use the existing reducer ownership, copy-on-write, retention budget, reset, rewind, and branch reconstruction rules.
This is an additive public SDK contract requiring maintainer review. Exhaustive event consumers need a case for the new event; clients projecting transcript links must use the published content shape.
## Scope and Constraints
Store references only. This change performs no URI fetch, upload, preview, or download and creates no `attachmentId`. It does not change model capability, local-file trust checks, native attachment storage, or the frontend renderer. The separate mid-turn ACP content admission policy is unchanged.
## Validation and Acceptance
Cover original prompt recording, recorder output, replay, SDK normalization/reduction, and offline reconstruction. Include text with two same-name/different-URI links, attachment-only messages, repeated echo, the same URI across turns, metadata, trusted model-only input, and URI preservation for `transit://`, `https://`, and `file://`.
Verify that reset/rewind removes erased references and reconstruction selects the active branch. Confirm reference-only handling without network requests. Daemon acceptance should inspect saved JSONL and reconstruct history after reconnect or restart. Package tests and daemon acceptance remain separate from a deployed downstream browser refresh check.

View file

@ -0,0 +1,39 @@
# 用户资源链接的持久化
[English](daemon-user-resource-links.md) | [简体中文](daemon-user-resource-links.zh-CN.md)
## 状态
已在 [#11178](https://github.com/QwenLM/qwen-code/issues/11178) 的拟议修复中实现,等待维护者评审和发布。验证结果记录在 PR 中。
## 问题
ACP 用户 prompt 可以包含携带 URI 和展示元数据的 `resource_link`。实时客户端可以在本地生成附件卡片,但这不能证明已持久化。ACP session 只记录用户文字和 Daemon 原生附件引用,URI 链接在 transcript 回放前已经丢失。SDK 的归一化也会丢弃这些链接。
[PR #11194](https://github.com/QwenLM/qwen-code/pull/11194) 处理了 SDK 投影,但假定本地文件的模型输入就是持久化的用户记录。原始引用必须独立于模型输入展开保存。
## 持久化和回放
将原始 ACP `resource_link` 块保存到现有用户记录的可选 `systemPayload.resourceLinks` 集合。引用来自公开 prompt,覆盖纯附件 prompt,以及模型输入被可信上下文替换的 prompt。传递给 recorder 前对引用生成快照。
现有回放机将保存的块作为 `user_message_chunk` 内容发出,并携带记录的 source、segment、prompt 和 branch 身份。保留 URI、name、MIME type、size、description、title、annotations 和 `_meta`,包括合法的 ACP nullable 字段。原生 `attachmentReferences` 继续使用既有 hydration 契约。
不从 `fileData`、生成的 `@uri` 文字或模型展开后的文件内容推断原始链接。这些形式有信息损失,也可能表示正常的远程媒体而非 ACP 附件。旧记录如果没有保存引用,则无法恢复已丢失的卡片。
## SDK 契约
新增类型化事件 `user.resource_link.delta`,携带 `resourceLink: DaemonResourceLink`。引用保留 ACP 内容判别字段 `type: 'resource_link'`。ACP update 的元数据继续保存在事件独立的 `meta` 字段中。
用户 transcript 块提供可选的 `resourceLinks: DaemonResourceLink[]`。在所属用户块内按 URI 去重,重复回显补齐缺失的元数据。同名不同 URI 保持独立,同一 URI 在不同轮次各自保留。复用现有 reducer 的消息归属、写时复制、保留容量、reset、rewind 和分支重建规则。
这是增量的公开 SDK 契约,需要维护者评审。对事件做穷尽处理的消费者需要新增分支;投影 transcript 链接的客户端应使用正式发布的内容形状。
## 范围和约束
仅保存引用。本次变更不获取 URI、不上传、不预览、不下载,也不创建 `attachmentId`。不改变模型能力、本地文件信任检查、原生附件存储或前端 renderer。独立的轮次内 ACP 内容接收策略保持不变。
## 验证和验收
覆盖原始 prompt 记录、recorder 输出、回放、SDK 归一化和归并,以及离线重建。包含文字加两个同名不同 URI 链接、纯附件消息、重复回显、跨轮次复用 URI、元数据、可信模型专用输入,以及 `transit://`、`https://`、`file://` 的 URI 保留。
验证 reset/rewind 删除被擦除的引用,重建选择当前分支;确认只处理引用而不发起网络请求。Daemon 验收应读取保存的 JSONL,并在重连或重启后重建历史。包测试和 Daemon 验收与已部署下游的浏览器刷新验证分别报告。

View file

@ -0,0 +1,199 @@
# Direct External Context Mem0 Presets
**Status:** Implemented in the private direct integration
**Date:** 2026-08-27
**Related designs:**
[Direct External Context Provider](./direct-external-context-provider.md),
[Direct External Context Mem0 Write](./direct-external-context-mem0-write.md),
[External Context Provider Extensions](./external-context-provider-extensions.md)
## Decision
The private Direct External Context integration uses one `mem0` provider type
with an administrator-selected, versioned built-in preset for Mem0-compatible
REST services. A preset owns the upstream wire contract; instance
configuration owns only the deployment endpoint, credential reference, fixed
scope, and timeout inherited from the direct profile.
This replaces provider types that combine a product name with one hard-coded
protocol, such as `polardb-mem0`. It does not expose a free-form API version,
request template, custom header map, JSONPath expression, or dynamic provider
module. The model-facing MCP contract remains `context_search({ query })` and,
only for an explicitly enabled version 1 configuration whose preset defines a
verified direct-import operation, `context_remember({ content })`.
The existing `mem0-platform-v3` configuration remains accepted for backward
compatibility. New deployments use `type: "mem0"`.
## Why a preset instead of `apiVersion`
One upstream product can mix operation versions. PolarDB Mem0 search and
direct import, for example, use different versioned paths. Authentication,
scope placement, result fields, write response semantics, and trailing-slash
requirements also vary independently of a numeric API version.
A preset therefore identifies one complete, verified contract. Published
preset identifiers are immutable. An incompatible upstream change receives a
new identifier rather than silently changing an existing mapping.
The first built-in presets are:
- `mem0-platform-v3`
- `mem0-oss-rest-2026-08`
- `aliyun-polardb-mysql-2026-08`
## Configuration
```json
{
"version": 1,
"timeoutMs": 5000,
"provider": {
"type": "mem0",
"preset": "aliyun-polardb-mysql-2026-08",
"endpoint": {
"origin": "https://memory.example.com",
"basePath": ""
},
"credentialEnv": "MEM0_API_KEY",
"scope": {
"userId": "repository-memory",
"agentId": "qwen-code"
}
}
}
```
`origin` contains only scheme and authority. `basePath` is a static optional
reverse-proxy prefix. They are validated separately so joining a preset path
cannot discard a configured prefix or reinterpret endpoint authority.
Embedded URL credentials, query strings, fragments, dot segments, encoded path
material, whitespace, and control characters are rejected from endpoint
configuration.
HTTPS is required by default. Loopback HTTP is accepted for local relays.
Non-loopback HTTP requires the explicit `allowInsecureHttp` flag and sends the
credential and memory content in cleartext; it is intended only for deployments
whose trusted private network is deliberately part of the boundary.
The preset declares which scope values it consumes and whether each value is
required or optional. Startup fails closed when a required value is missing or
when configuration supplies a value the selected preset does not use. Scope is
administrator input and never appears in tool arguments.
One absolute `QWEN_EXTERNAL_CONTEXT_CONFIG` path is loaded once per MCP child.
The path, file contents, endpoint, preset, scope, and credential-to-corpus
binding must remain immutable for the whole Qwen session, including MCP child
restarts. Switching any of them requires a new Qwen session and a new config
path.
## Bounded preset contract
Built-in presets select only reviewed constants:
- `Authorization: Token`, `Authorization: Bearer`, or `X-API-Key`
- one static POST search path
- `top_k` or `limit` as the result-limit field
- `user_id`, `agent_id`, and `app_id` placed at the JSON root, under
`filters`, or omitted
- a closed set of fixed search options such as `threshold` and `rerank`
- a `results` response collection with reviewed identifier and content fields
- an optional static direct-import path and one reviewed response mapping
The engine always sends at most five as the provider limit and retains at most
five valid results. It does not retry, redirect, probe alternate paths, or
fallback between presets. A malformed item is dropped independently; a
malformed response envelope fails the request.
Adding a preset requires authoritative protocol evidence plus request and
response contract tests. A service that cannot fit this grammar uses its own
local or remote MCP Extension under the External Context Provider Extensions
design instead of expanding this configuration into a programming language.
## Initial mappings
### Mem0 Platform V3
- Search: `POST /v3/memories/search/`
- Authentication: `Authorization: Token`
- Scope: required `appId` under `filters.app_id`
- Limit: `top_k`
- Direct import: `POST /v3/memories/add/`, `infer: false`
- Write response: `PENDING` plus a UUID `event_id` is `accepted`; only
`SUCCEEDED` is `stored`
The legacy `mem0-platform-v3` configuration remains a fixed-endpoint shorthand
for this mapping.
### Mem0 OSS REST 2026-08
- Search: `POST /search`
- Authentication: `X-API-Key`
- Scope: required `userId` and optional `agentId` under `filters`
- Limit: `top_k`
- Direct import: `POST /memories`, `infer: false`
- Write response: a valid `results[].id` is `stored`; otherwise `unknown`
This mapping follows the stock `mem0ai/mem0` REST server. Bearer-authenticated
deployments need a separately verified preset rather than a per-instance
authentication override.
### Aliyun PolarDB MySQL 2026-08
- Search: `POST /v2/memories/search`
- Authentication: `Authorization: Token`
- Scope: required `userId` under `filters`, optional top-level `agentId`
- Limit: `top_k`
- Direct import: `POST /v1/memories`, `infer: false`
- Write response: a valid `results[].id` is `stored`; otherwise `unknown`
An `event_id` alone is never treated as proof of storage. If a later PolarDB
contract documents asynchronous event polling, that behavior requires a new
preset and write design rather than changing this preset in place.
## Protocol evidence
- Mem0 Platform V3 follows the official
[search API](https://docs.mem0.ai/api-reference/memory/search-memories) and
[V2-to-V3 migration contract](https://docs.mem0.ai/migration/platform-v2-to-v3).
- Mem0 OSS REST is pinned to upstream commit
[`39bc023`](https://github.com/mem0ai/mem0/tree/39bc02330563764e7d4465f1ecff5f002d94da1a),
specifically [`server/main.py`](https://github.com/mem0ai/mem0/blob/39bc02330563764e7d4465f1ecff5f002d94da1a/server/main.py)
and [`server/auth.py`](https://github.com/mem0ai/mem0/blob/39bc02330563764e7d4465f1ecff5f002d94da1a/server/auth.py).
- The PolarDB preset follows the official
[PolarDB for MySQL Mem0 contract](https://help.aliyun.com/en/polardb/polardb-for-mysql/use-polardb-mem0)
and the [live end-to-end evidence](https://github.com/QwenLM/qwen-code/pull/9952#issuecomment-5407141853)
recorded for this PR. The public documentation covers the endpoint,
authentication, and scope placement; the live instance additionally proves
that `infer: false` is honored for direct import.
Tests must continue to pin these exact request and response shapes. If an
upstream contract changes incompatibly, add a new preset identifier instead of
updating an existing mapping in place.
## Relationship to provider Extensions
This provider is a bounded compatibility feature inside the already private
`integrations/external-context` process. It is not a public provider registry.
Third-party teams still own independently distributed integrations through
MCP Extensions. The direct preset path is appropriate only for Mem0-family
contracts that Qwen maintainers explicitly verify and agree to maintain.
Operators must enable either this direct server or another Mem0 Extension for
one corpus, not both. Exposing two `context_search` tools for the same corpus
would make provider selection model-controlled and could duplicate queries.
## Verification
The implementation must prove:
- strict configuration parsing and fail-closed preset/scope validation
- safe `origin` plus `basePath` joining
- exact authentication, path, scope, and limit mapping for every preset
- per-item response normalization and the five-result cap
- conservative synchronous versus asynchronous write outcomes
- no retry on search or write failures
- loadability of the shipped PolarDB and OSS examples
- compatibility of the existing `mem0-platform-v3` configuration

View file

@ -18,9 +18,13 @@ configuration containing the strict `write: { enabled: true }` block. The
default extension manifest, existing version 1 configurations, Generic HTTP,
and version 2 auto-recall remain write-free.
The tool sends the validated text unchanged through Mem0 V3 Direct Import with
`infer: false`. It does not pre-search, summarize, normalize, retry, poll, cache,
or deduplicate. A separately installed `PreToolUse` command Hook displays a
The tool sends the validated text unchanged through the selected, verified
Mem0 direct-import preset with `infer: false`. The legacy
`mem0-platform-v3` configuration remains supported. New `mem0` configurations
select an immutable built-in preset as defined in
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md).
The tool does not pre-search, summarize, normalize, retry, poll, cache, or
deduplicate. A separately installed `PreToolUse` command Hook displays a
reversible escaped representation of the complete text and asks the user to
confirm it. This confirmation is a direct-profile user-experience safeguard,
not a server-side authorization boundary.
@ -30,8 +34,9 @@ not a server-side authorization boundary.
### Goals
- Let trusted collaborators save exact repository-shared text to one
administrator-bound Mem0 Project.
- Keep the Project credential and `app_id` outside model-controlled input.
administrator-bound Mem0 corpus.
- Keep the credential, endpoint, preset, and fixed scope outside
model-controlled input.
- Make the complete text visible before the MCP call executes.
- Perform at most one Provider request per approved tool invocation.
- Represent asynchronous and ambiguous Provider outcomes without claiming
@ -56,7 +61,7 @@ sequenceDiagram
participant Q as Qwen Code
participant H as PreToolUse Hook
participant E as External Context MCP
participant P as Bound Mem0 Project
participant P as Bound Mem0 corpus
M->>Q: context_remember(content)
Q->>Q: Ordinary MCP permission check
Q->>H: Exact tool name, mode, and content
@ -121,6 +126,12 @@ configuration validation. The default extension manifest still includes only
linking. Administrators must use the dedicated pinned MCP configuration whose
`includeTools` contains exactly search and remember.
New Mem0-compatible deployments may replace the legacy provider block with a
strict `type: "mem0"` block containing one built-in preset, endpoint,
credential reference, and preset-valid fixed scope. Writes are accepted only
when that preset defines a reviewed direct-import mapping. A custom endpoint
does not imply a custom write protocol.
The remember tool annotations are `readOnlyHint: false`,
`destructiveHint: false`, `idempotentHint: false`, and `openWorldHint: false`.
They describe behavior for clients; they are not permission or authorization.
@ -174,7 +185,7 @@ content sent to Mem0.
## Mem0 request and result semantics
The adapter sends exactly one request:
The legacy Platform V3 adapter sends exactly one request:
```http
POST /v3/memories/add/
@ -196,19 +207,28 @@ detection, so approving the same text twice can create two memories. The
integration intentionally does not add a hidden search or content hash because
neither would provide idempotency for an async remote operation.
The versioned `mem0` adapter also sends exactly one request, using only the
selected preset's fixed path, authentication, and scope placement. Platform V3
uses its asynchronous add response. The initial OSS REST and PolarDB presets
claim `stored` only for a valid `results[].id`; a bare `event_id`, alternate
root object, or root array is `unknown`. The client never treats request
acceptance as proof of persistence.
Result mapping is conservative:
| Provider outcome | Tool result |
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| Valid `SUCCEEDED` | `stored` |
| Valid `PENDING` with UUID `event_id` | `accepted` with operation ID |
| Explicit `FAILED`, or HTTP 400, 401, 403, or 404 | `failed` with stable MCP error |
| Valid asynchronous `PENDING` with a UUID `event_id` | `accepted` with operation ID |
| Valid synchronous direct-import `results[].id` for a preset that defines it | `stored` with memory ID |
| Explicit `FAILED` in an asynchronous status response, or HTTP 400, 401, 403, or 404 | `failed` with stable MCP error |
| Timeout, cancellation, redirect, other HTTP status, broken or oversized response, invalid JSON, unknown status, or invalid identifier | `unknown` with MCP error |
Mem0 Add normally returns `PENDING`, so `accepted` is the expected successful
result and means queued, not persisted. `stored` is retained only for a valid
synchronous `SUCCEEDED` response. `failed` is a definitive rejection and tells
the model not to retry without changing the content or configuration.
Mem0 Platform Add normally returns `PENDING`, so `accepted` is the expected
successful result and means queued, not persisted. `stored` is retained only
for a valid synchronous `SUCCEEDED` response or a preset-defined synchronous
direct-import result. `failed` is a definitive rejection and tells the model
not to retry without changing the content or configuration.
`unknown` states that the Provider may already have accepted the write and
tells the model not to retry automatically. The integration never polls the
event or retries. User cancellation likewise does not prove that no record was

View file

@ -19,10 +19,14 @@ The optional Phase 4 Mem0 variant adds an administrator-only
`context_remember({ content })` surface with a separate content-visible
confirmation Hook. Its detailed design is in
[Direct External Context Mem0 Write](./direct-external-context-mem0-write.md).
Versioned Mem0-compatible request mappings are defined in
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md).
The extension supports two explicit read adapters:
The extension supports three explicit read paths:
- Mem0 Platform V3 Search for repository-shared agent memory.
- The legacy fixed-endpoint Mem0 Platform V3 adapter.
- A bounded `mem0` adapter whose reviewed built-in preset selects Mem0
Platform V3, stock Mem0 OSS REST, or Aliyun PolarDB Mem0.
- Generic HTTP Search V1 for an existing knowledge base, RAG service, or
enterprise search endpoint.
@ -144,9 +148,11 @@ The interface deliberately contains no tenant, user, repository, namespace,
application ID, or arbitrary filter. The explicit provider factory binds those
values from administrator-controlled configuration before a tool call.
Neither interface is a public package API. Adding another provider requires a
reviewed adapter and an explicit factory case. Generic HTTP remains
search-only; write semantics are deliberately provider-specific.
Neither interface is a public package API. Adding another provider family
requires a reviewed adapter and an explicit factory case. A Mem0-family wire
contract that fits the bounded preset grammar instead adds one immutable
built-in preset with contract tests. Generic HTTP remains search-only; write
semantics are deliberately provider-specific.
## Runtime behavior
@ -245,6 +251,12 @@ and a shorter provider timeout.
}
```
New Mem0-compatible deployments use `type: "mem0"` with a versioned preset,
an `endpoint` containing separately validated `origin` and `basePath`, a
`credentialEnv`, and a fixed `scope`. The complete schema and compatibility
policy are defined in the Mem0 preset design. The legacy
`mem0-platform-v3` shape above remains accepted.
The managed launcher must control the configuration path and credential. An
MCP subprocess does not reload either value, but Qwen can restart the
subprocess after a disconnect or explicit MCP restart. The configuration path,
@ -323,6 +335,21 @@ provider-side state change must verify that Memory Decay remains disabled.
Provider audit or access logs may still be retained. See
[Mem0 Memory Decay](https://docs.mem0.ai/platform/features/memory-decay).
### Versioned Mem0-compatible Search
The `mem0` adapter selects one immutable built-in preset at startup. The
initial presets cover Mem0 Platform V3, the stock Mem0 OSS REST server, and
Aliyun PolarDB Mem0. Each preset fixes authentication, search and direct-import
paths, scope placement, limit field, response fields, and write outcome
semantics. Instance configuration cannot override those fields.
The endpoint uses a separately validated origin and static base path. Search
always sends at most five results, never follows redirects, never retries, and
does not probe or fall back between protocol versions. Scope remains fixed
outside model input. See
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md)
for the exact mappings and extension boundary.
### Generic HTTP Search V1
The configured `baseUrl` must be an origin with no path, query, credentials, or
@ -445,7 +472,8 @@ The optional auto-recall profile is implemented separately in
The optional Mem0 write variant is implemented separately in
[Direct External Context Mem0 Write](./direct-external-context-mem0-write.md).
The broader proposal in #7585 retains possible additional provider-specific
adapters where the Generic HTTP contract is not sufficient.
Extensions where neither Generic HTTP nor the bounded Mem0 preset contract is
sufficient.
The remaining items are not latent switches in either direct profile. They
require separate review and implementation.
@ -473,3 +501,4 @@ require separate review and implementation.
- [Mem0 Organizations & Projects](https://docs.mem0.ai/api-reference/organizations-projects)
- [Mem0 Search Memories](https://docs.mem0.ai/api-reference/memory/search-memories)
- [Mem0 OSS server](https://github.com/mem0ai/mem0/tree/main/server)

View file

@ -222,6 +222,13 @@ result to its existing `context_search`; it does not change Provider HTTP
requests, result ranking, write behavior, configuration schemas, or Auto
Recall output.
That private integration may carry a bounded set of maintainer-owned,
versioned Mem0 presets as documented in
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md).
Those built-ins are a compatibility surface for verified Mem0-family
contracts, not a public registry or an alternative distribution path for
third-party providers.
The reference MCP now rejects unrecognized `context_search` arguments instead
of silently ignoring them. Existing query-only calls are unchanged. A client
that sent undeclared selector or metadata fields must remove those fields; the

View file

@ -0,0 +1,213 @@
# External reasoning overrides, bounded redesign
[English](external-reasoning-overrides-v2.md) | [简体中文](external-reasoning-overrides-v2.zh-CN.md)
Status: implemented; local validation passed. Supersedes the implementation
approach in PR #11521. The new PR must stay within 3,500 changed lines, counting
additions and deletions across production code, tests, generated files and docs.
## 1. Goal and boundary
Let consumers override a model's reasoning format, supported effort tiers and
default through settings. The default must affect real requests and the UI.
Support new names and gateway aliases that reuse an existing provider protocol.
Apply reasoning changes before the next user prompt; preserve the active prompt,
including its tool continuations, retries and derived agents.
Reuse `modelProviders[].capabilities.reasoning`. Do not introduce a parallel
`generationConfig.reasoningConfig`, per-effort budget mapping, request template
language or new authentication protocol. Do not redesign the model registry,
image-model lifecycle, credential refresh, scheduling, runtime snapshot restore
or generic provider error handling. Endpoint and credential changes are outside
this feature's next-prompt guarantee; keep their existing lifecycle.
Main already exposes reasoning capabilities to model discovery and controls,
and some adapters read them. The gaps are consistent default application,
explicit format selection for aliases, and a stable reasoning view during a
prompt. Fill those gaps instead of adding a second configuration system.
## 2. Public configuration
Known model, changing only its default:
```json
{
"id": "qwen3.8-max",
"capabilities": {
"reasoning": { "defaultEffort": "medium" }
}
}
```
New model on an existing OpenAI-compatible provider:
```json
{
"id": "company-model-v2",
"baseUrl": "https://gateway.example.com/v1",
"envKey": "COMPANY_MODEL_API_KEY",
"capabilities": {
"reasoning": {
"profile": "openai-effort",
"efforts": ["low", "medium", "high"],
"defaultEffort": "medium"
}
}
}
```
Use existing `efforts` and `defaultEffort`; add only optional `profile`.
Accept partial declarations and resolve them before the existing strict
capability parser. Separate the partial input type from the complete resolved
`ModelReasoningCapabilities`; downstream consumers never receive partial values.
Preserve the existing complete declarations, including
`thinking`, `toggleOnly`, `canDisable` and `disableField`, without migration.
Do not add new on/off policy fields in this PR.
| Field | Responsibility |
| --------------- | ---------------------------------------------------------------------------------------- |
| `profile` | Select an existing reasoning wire strategy; never select an SDK, endpoint or credential. |
| `efforts` | Replace the supported subset of `low/medium/high/xhigh/max`. |
| `defaultEffort` | Supply the effort when no explicit selection or existing raw override takes precedence. |
Omitted fields inherit capabilities from the existing provider catalog at the
selected endpoint; exact model IDs take precedence over normalized aliases.
Unknown gateway routes require an explicit declaration. Replacing `efforts` replaces
the array rather than merging it. An inherited default is clamped using the
existing helper; an explicitly supplied default must belong to the effective
set. Unknown tiered models must declare `profile`, `efforts` and `defaultEffort`;
do not invent a default or add a new model-name table. Toggle-only profiles need
no effort fields and reject them. Keep the supported canonical tier vocabulary.
Profiles are a closed set of existing formats:
| Protocol | Profiles and existing wire shapes |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `openai-effort`: flat effort; `openai-reasoning`: nested reasoning; `deepseek-openai`: DeepSeek thinking and reasoning-history format. |
| Qwen over OpenAI Chat | `dashscope-effort`: tiered effort; `dashscope-thinking`: top-level toggle; `qwen-chat-template`: nested template toggle. |
| OpenAI Responses | `openai-reasoning`: existing Responses reasoning format. |
| Anthropic | `anthropic-manual`: enabled thinking and fixed/derived budget; `anthropic-adaptive`: adaptive thinking and output effort; `deepseek-anthropic`: existing compatible format. |
| Gemini/Vertex | `gemini`: existing thinking-level mapping, limited to low/medium/high. |
An explicit adaptive profile always selects adaptive thinking. A consumer that
needs manual budgets selects the manual profile. Do not add an adaptive-only
synonym. Omitted profile preserves current inference. Explicit profiles replace
reasoning-format inference, not endpoint-specific non-reasoning restrictions.
With an explicit profile, derive wire controls from it; `disableField` remains a
legacy format hint only when profile is omitted. Do not carry two competing
format selectors into adapter execution.
## 3. Resolution and request flow
One pure resolver produces the effective capability, default and wire strategy
from the exact route, its declaration and existing built-ins. UI and adapters
consume that result. Resolve against the actual selected route, not a registry
placeholder endpoint. An explicit profile eliminates hostname inference for
reasoning format. Never obtain another endpoint's declaration by model ID alone.
For an entry without `baseUrl`, preserve its existing registry selection identity
and resolve inference against the selected runtime endpoint. An explicit endpoint
override that selects a different route must resolve its own declaration.
Keep model defaults separate from user choices. Determine the effective effort
as existing explicit session/request selection, then the resolved model default.
Use the existing supported-tier reconciliation once; do not add a second
name-based clamp after an explicit capability has resolved the tier.
`generationConfig.reasoning`, `samplingParams` and `extra_body` keep their current
provider precedence and escape-hatch behavior. Profile shaping substitutes for
the existing inferred reasoning stage; it is not a final whole-body rewrite.
Apply defaults below existing raw overrides and retain request opt-out and
existing mandatory-thinking handling. Reuse each adapter's current projection
for recognized raw overrides. Where raw input prevents determining an effective
tier, use the existing unspecified/blocked control state instead of falsely
claiming the configured default was sent. Do not reconcile arbitrary conflicting
raw fields, expand their accepted shapes, or add a new override UI.
Small provider-specific changes select existing serialization and required
thinking-history preparation together. Keep tool choice, sampling, authentication,
cache behavior and non-reasoning history rules in their existing owners.
Unsupported future protocols require adapter work, not another configurable DSL.
## 4. Next-user-prompt lifecycle
Maintain one immutable reasoning-only table per admitted prompt. Each entry uses
the existing exact route identity and contains reasoning metadata only. Capture
all configured reasoning routes so a side query or subagent created after a disk
reload still sees the parent prompt's version. Reuse route identity helpers.
Settings observation prepares the latest valid reasoning table. At the existing
user-prompt admission boundary, replace the active table once. A changed table
does not replace the current generator, call `refreshAuth`, switch models, update tools or
replace a registry. Cached side-model views are invalidated on adoption; existing
views retain their captured table. Normal authentication refresh carries the current active
reasoning table forward; it cannot promote pending reasoning changes.
Adapters read the captured table, not live registry reasoning. A derived agent
inherits the captured table but resolves its own route. An unknown route uses its
own existing built-ins; it never inherits the parent's capability. Existing
model-switch flows retain their route and preference rules and resolve reasoning
from the active table. The next user prompt promotes the latest table.
Automatic retries, tool continuations and Goal/background continuations do not
promote pending reasoning. Newly created sessions use the latest configuration.
Cold model previews may read the latest validated table; an existing session's
controls display its active table and update when that session adopts the change.
Runtime resolution of invalid declarations returns no override, preserving
existing behavior even in a fresh session. Strict staging validation rejects
invalid reasoning updates and keeps the previous valid table. The CLI prints
a warning; ACP reload sends a discrete message naming the model/field.
They must not reject unrelated
settings updates or erase healthy model rows. Validate static fields independently
from endpoint inference; no global boot failure based on placeholder URLs.
## 5. Implementation and size budget
Start from a fresh main-based branch; do not cherry-pick the old implementation.
Implement in four bounded groups: capability resolution; existing adapter hooks;
prompt reasoning snapshot propagation; controls and configuration documentation.
Use direct core-module imports in CLI production code. No unrelated cleanup.
| Group | Changed-line budget |
| ----------------------------------------------------- | ------------------: |
| Production implementation | 1,410 |
| Focused unit tests | 1,390 |
| Local E2E harness and scenarios | 270 |
| Both design languages, user docs and generated schema | 430 |
| Review reserve | 0 |
| Total hard limit | 3,500 |
Count additions plus deletions in the final PR diff against its main merge base,
including renames, generated schemas and every test/document shipped. Track
uncommitted/new files too. Do not hide implementation in ignored files or omit
necessary tests to meet the limit. Exceeding a group budget requires simplifying
within this scope; exceeding 3,500 blocks submission rather than silently growing
the limit. Merge-only upstream changes do not count, but conflict-resolution
changes that remain in the PR do.
## 6. Validation and acceptance
1. Known Qwen 3.8 Max with only default medium: controls select medium and the
actual request sends medium; no "Use model default" menu item.
2. Unknown aliases for each listed format: declared tiers/default are used and
final request shape matches the selected existing strategy. Cover toggle-only,
manual/adaptive thinking, supported-tier reconciliation and explicit off.
3. No declaration: request bodies and existing controls remain unchanged. Existing
complete capability declarations still work; raw override behavior is preserved.
4. Exact same-name endpoints and model switching: no capability leakage, model
default is not persisted as user preference, explicit selections follow the
existing reconciliation policy. Include Qwen, DeepSeek and Kimi switching.
5. Reload twice during a tool-using prompt: requests, retries and newly created
child agents retain the original table; next user prompt uses the latest valid
one. Include a later image/endpoint update to prove this mechanism does not
introduce image/credential staging or transactions.
6. Invalid profiles, tiers, defaults and endpoint-dependent inference: preserve
the previous reasoning configuration and healthy model discovery with a useful
error; never send a fabricated fallback profile.
Record the installed CLI baseline, then test a local built bundle against
controlled endpoints using dummy credentials. Run package-local focused tests,
full build/typecheck, lint and actual local E2E; perform two clean diff audits.
Publish the E2E report and line-count breakdown with the new PR. Passing tests do
not authorize scope expansion. Local wire and browser validation uses controlled
endpoints; it does not establish acceptance by real provider services.

View file

@ -0,0 +1,174 @@
# 外部思考配置覆盖:限定范围的重新设计
[English](external-reasoning-overrides-v2.md) | [简体中文](external-reasoning-overrides-v2.zh-CN.md)
状态:已实现,本地验证通过。替代 PR #11521 的实现方案。新 PR 的总变更
不得超过 3500 行,按生产代码、测试、生成文件和文档的新增行与删除行之和计算。
## 1. 目标与边界
允许使用方通过 settings 覆盖模型的思考格式、支持档位及默认档位。默认值必须
同时作用于实际请求和界面。支持复用现有 provider 协议的新模型名与网关别名。
思考配置在下一次用户发送前生效;当前轮及其工具续跑、重试、派生代理保持原配置。
复用 `modelProviders[].capabilities.reasoning`,不再引入平行的
`generationConfig.reasoningConfig`、分档预算映射、请求模板语言或新鉴权协议。
不重做模型注册表、图片模型生命周期、凭证刷新、调度、运行时快照恢复或通用
provider 错误处理。端点和凭证变更不属于本功能“下一次发送生效”的保证,
继续沿用它们已有的生命周期。
主分支已经把思考能力提供给模型发现与控件,部分适配器也读取这些能力。
真正需要补齐的是默认值一致生效、别名显式选择格式,以及当前轮稳定的思考视图。
填补这些缺口,不建立第二套配置系统。
## 2. 公开配置
已知模型只修改默认档位:
```json
{
"id": "qwen3.8-max",
"capabilities": {
"reasoning": { "defaultEffort": "medium" }
}
}
```
在已有 OpenAI 兼容 provider 上声明新模型:
```json
{
"id": "company-model-v2",
"baseUrl": "https://gateway.example.com/v1",
"envKey": "COMPANY_MODEL_API_KEY",
"capabilities": {
"reasoning": {
"profile": "openai-effort",
"efforts": ["low", "medium", "high"],
"defaultEffort": "medium"
}
}
}
```
继续使用已有的 `efforts` 和 `defaultEffort`,只新增可选 `profile`。
允许部分声明,先补齐再交给已有严格能力解析器。部分输入类型与完整解析结果
`ModelReasoningCapabilities` 分开,下游消费方不接收半成品。已有完整声明及其中的
`thinking`、`toggleOnly`、`canDisable`、`disableField` 无须迁移。
本 PR 不新增思考开关策略字段。
| 字段 | 职责 |
| --------------- | -------------------------------------------------------- |
| `profile` | 选择已有思考请求策略,不选择 SDK、端点或凭证。 |
| `efforts` | 替换 `low/medium/high/xhigh/max` 中受支持的档位子集。 |
| `defaultEffort` | 在没有显式选择或优先级更高的已有原始覆盖时提供默认档位。 |
省略字段根据实际端点从已有 provider 列表继承能力,精确模型 ID 优先于归一后的别名。
未知网关路由需要显式声明。`efforts` 整体替换,不合并数组。继承的默认值用已有
函数归一到有效集合;显式指定的默认值必须属于有效集合。未知分档模型必须声明
`profile`、`efforts` 和 `defaultEffort`,不替使用方猜默认值,也不增加新模型名表。
仅开关 profile 不需要档位字段,并拒绝这些字段。保留现有规范档位词表。
Profile 是已有格式的封闭集合:
| 协议 | Profile 与已有请求格式 |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `openai-effort`:扁平 effort;`openai-reasoning`:嵌套 reasoning;`deepseek-openai`:DeepSeek 思考及推理历史格式。 |
| OpenAI Chat 上的 Qwen | `dashscope-effort`:分档 effort;`dashscope-thinking`:顶层开关;`qwen-chat-template`:嵌套模板开关。 |
| OpenAI Responses | `openai-reasoning`:已有 Responses reasoning 格式。 |
| Anthropic | `anthropic-manual`:enabled thinking 与固定或派生预算;`anthropic-adaptive`:adaptive thinking 与输出 effort;`deepseek-anthropic`:已有兼容格式。 |
| Gemini/Vertex | `gemini`:已有 thinking-level 映射,只支持 low/medium/high。 |
显式 adaptive profile 始终选择 adaptive thinking。需要手动预算的使用方选择
manual profile,不增加 adaptive-only 同义配置。省略 profile 保留已有推断。
显式 profile 替代思考格式推断,不覆盖端点专属的非思考限制。
显式 profile 决定请求控制字段;只有省略 profile 时,才把 `disableField` 作为
旧格式提示,不能让两种格式选择器同时进入适配器竞争。
## 3. 解析与请求链路
一个纯函数根据精确路由、外部声明和已有内置规则,得到有效能力、默认值与请求
策略,供界面和适配器共同使用。依照实际选中路由解析,不能使用注册表占位端点
做最终判断。显式 profile 不再依赖主机名推断思考格式。不能仅凭模型 ID 取用
另一个端点的声明。
未声明 `baseUrl` 的条目保留已有注册表选择身份,以选中的运行时端点完成格式推断。
显式覆盖端点并选择不同路由时,必须解析目标路由自己的声明。
区分模型默认值与用户选择。先使用已有会话或请求显式选择,再使用解析后的模型
默认值。只执行一次已有档位归一;声明已确定有效档位后,不能再按模型名二次截断。
`generationConfig.reasoning`、`samplingParams`、`extra_body` 保持各 provider
已有优先级与直接配置行为。Profile 替代已有的思考推断环节,不做最终整份请求体
重写。默认值低于已有原始覆盖;保留请求级关闭和已有强制思考处理。复用适配器对
可识别原始覆盖的已有状态映射。无法由原始输入确定实际档位时,使用已有的未指定
或不可调整控件状态,不能声称实际发送了模型默认档位。不调和任意互相矛盾的原始
字段,不扩展其接受形态,不新增覆盖状态界面。
各 provider 只增加小范围策略选择,同时选择已有序列化及必要的思考历史处理。
工具选择、采样、鉴权、缓存和非思考历史规则保留在原来的归属位置。未来不受支持
的协议通过适配器开发支持,不增加另一套可配置语言。
## 4. 下一次用户发送生效
每次用户发送进入执行时,持有一份不可变的思考配置表。表项使用现有精确路由身份,
只包含思考元数据。捕获全部已配置思考路由,确保磁盘重载后才创建的旁路请求或
子代理仍能使用父轮次版本。复用路由身份辅助函数。
配置观察链路准备最近一次有效思考配置表。在已有用户发送入口一次性替换当前表。
更新配置表不替换当前 generator、不调用 `refreshAuth`、不切模型、不刷新工具,
也不替换注册表。采用更新时清除旁路模型缓存,已经持有的视图仍保留原表。正常鉴权刷新继续携带当前思考配置表,不能提前采用待更新内容。
适配器读取捕获的表,不读取实时注册表中的思考配置。派生代理继承表,但解析自身
路由。未知路由使用自身已有内置规则,不继承父模型能力。已有切换模型流程保留
路由与偏好规则,从当前表解析思考配置;下一次用户发送再采用最近一次配置表。
自动重试、工具续跑及 Goal/后台续跑不采用待更新配置。新建会话使用最新配置。
冷启动模型预览可以读取最新有效表;已有会话控件展示当前表,在该会话采用更新
时同步变化。
运行时遇到无效声明时返回无覆盖,新建会话也能继续沿用原有行为。严格的暂存校验
拒绝无效更新,保留上一份有效思考表;CLI显示警告,ACP重载发送离散消息,
说明模型及字段。不能阻断
无关设置更新或清空健康模型行。静态字段校验与端点推断分离,不允许占位 URL
造成全局启动失败。
## 5. 实现与行数预算
从 main 新建干净分支,不 cherry-pick 旧实现。按四个有界部分实现:能力解析、
已有适配器接入、轮次思考快照传递、控件和配置文档。CLI 生产代码直接从 core
定义模块导入,不夹带无关清理。
| 部分 | 变更行数预算 |
| ------------------------------- | -----------: |
| 生产实现 | 1410 |
| 定向单测 | 1390 |
| 本地 E2E 脚本与场景 | 270 |
| 双语设计、用户文档、生成 schema | 430 |
| 评审预留 | 0 |
| 总硬上限 | 3500 |
按最终 PR 相对 main 合并基点的新增行与删除行之和计数,计入重命名、生成 schema
和全部提交的测试与文档,同时跟踪尚未提交及新文件。不能把实现藏进忽略文件,
也不能为满足限额删除必要测试。某组超预算时须在原范围内简化;超过 3500 行
阻止提交 PR,不能静默提高上限。单纯合并上游的内容不计入,最终留在 PR 差异
中的冲突处理计入。
## 6. 验证与验收
1. 已知 Qwen 3.8 Max 只配置默认 medium:控件选中中,真实请求发送 medium,
不出现“使用模型默认值”菜单项。
2. 每种列出的格式使用未知别名:声明档位和默认值生效,最终请求符合已有策略。
覆盖仅开关、manual/adaptive thinking、档位归一和显式关闭。
3. 无声明时请求内容和已有控件不变;已有完整能力声明仍可用,原始参数覆盖行为
保持兼容。
4. 同名不同端点及模型切换:能力不泄漏,默认值不保存成用户偏好,显式选择沿用
已有归一规则。包含 Qwen、DeepSeek、Kimi 切换。
5. 当前轮调用工具期间重载两次:请求、重试和新建子代理保留原表,下一次用户发送
使用最近一次有效表。加入后续图片或端点更新,证明没有新增图片/凭证暂存事务。
6. 无效 profile、档位、默认值及依赖端点的推断:保留原思考配置及健康模型发现,
给出有效错误信息,不发送编造的兜底格式。
先记录已安装 CLI 基线,再用虚拟凭证和受控端点测试本地 bundle。执行包内定向
测试、完整 build/typecheck、lint 和真实本地 E2E,完成两次无新问题的差异自审。
新 PR 附 E2E 报告和行数分类统计。测试通过不能授权范围扩张。本地请求与浏览器
验收使用受控端点,不代表已验证真实提供方接受所有配置。

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,48 +0,0 @@
# Goal loop input control
## Problem
An active `/goal` is implemented as a blocking Stop hook. While the model is
running, the interactive queue normally defers slash commands until the stream
becomes idle. A goal loop may never reach that idle boundary, so `/goal clear`
and replacement `/goal` commands cannot take effect.
The Stop response can also aggregate the goal hook with unrelated configured
hooks. Clearing a goal must not discard a blocking decision owned by another
hook.
## Design
During an active turn, the message queue drains `/goal` commands alongside
plain-text steering messages. Other slash commands remain queued for normal
idle processing.
The CLI executes drained goal commands through the existing slash-command
processor:
- Clear commands apply their side effect without producing model input.
- Replacement commands replace the pending goal instruction.
- When multiple goal commands are drained together, only the instruction for
the final active goal is sent.
- The surviving instruction keeps its position relative to plain-text steering
messages.
- Executed goal commands are not restored if later steering preparation is
cancelled; unexecuted plain-text messages are restored.
Core samples the queue before Stop hooks and again after a blocking Stop hook
returns. A blocking goal output carries its goal hook ID and keeps its
continuation reason separate from ordinary hook reasons. The hook bridge also
reports whether another Stop output is blocking. If the goal changes at the
second boundary, core removes only the old goal continuation; it still follows
an independent blocking reason. Non-blocking hook outputs do not force an extra
goal iteration.
## Verification
- Queue tests cover active-turn goal draining and idle-boundary deferral.
- CLI stream tests cover clear, replacement, batched commands, ordering, and
restore behavior.
- Core tests cover clear and replacement during Stop-hook evaluation, including
an aggregated independent blocker.
- A local tmux session exercises clear and replacement against the built
interactive CLI.

View file

@ -0,0 +1,103 @@
# Linux CUA input recovery
[简体中文](linux-cua-input-recovery.zh-CN.md)
Linux's semantic-input fallback currently treats failure after dispatch as if
the semantic route were unavailable. A native action can mutate the application
and then lose its D-Bus reply; the click tool subsequently sends a pixel click.
The same fallback exists for scrolling. Some click paths also ignore a false
AT-SPI acknowledgement and report success.
The reproduction uses the released 0.20.8 source: an action effect followed by
an error or the existing 25-second timeout produces two fixture effects, one
semantic and one pixel. A false acknowledgement with no effect still returns
"Clicked element". These are source-extraction reproductions with controlled
transport endpoints; they are not claims about the causes of every historical
zero-score task.
Port the macOS recovery principle into the existing Linux implementation:
fallback is possible before input dispatch, but a possible dispatch must stop
automatic replay. Track dispatch locally in each AT-SPI attempt, including
timeouts and partially completed scroll batches. Return an unconfirmed-input
error after false, failed, or timed-out acknowledgements. The caller must
observe before deciding whether to retry. Preserve safe pre-dispatch fallback,
exact target identity and the current foreground guard.
The existing Node REPL → Computer Use facade → native Linux driver topology,
processes, installation and permissions remain unchanged. No new public API,
service, global retry policy or provider-specific workaround is introduced.
The canonical fixed Computer Use skill remains the model guidance source.
The same dispatch boundary applies to text insertion and value replacement.
An uncertain insert must not fall through to a whole-field replacement, and a
rejected replacement must not fall through to an append. Successful writes
without readback remain unverified; they must not automatically emit a
foreground escalation that the shared core interprets as failed delivery.
Preserve editable text independently of the accessible label, including an
empty value when clearing a field. Compact observation rows omit duplicate
label/value text, default states and the primary activation action, while the
structured elements retain all actions. Keep nondefault state and secondary
actions visible so actionable differences survive revision comparison.
Pixel fallbacks retain the exact window and element identity. Intersect the
element bounds with that window and recompute the point after foreground
preparation, preventing oversized controls from targeting a sibling window.
GTK3 already supplies correct screen coordinates with client-side decorations;
avoid applying GTK4's shadow reconstruction to that case. Preserve and verify
the GTK4 path separately. Its X11 top-level Screen extents are window-local,
so correlate a multi-window GTK4 application only through titles that are
unique in both its native and accessibility window lists. Missing or duplicate
titles continue to refuse. Semantic primary activation applies only to an
unmodified single left click, preserving requested right and double clicks.
The owned Linux desktop also reproduced active-window and core-focus readiness
taking 644–734 ms. Allow one second for the existing focus confirmation loop,
which still returns immediately when ready and retries only preparation.
The action callback remains single-shot; this does not replay uncertain input
or establish the cause of historical benchmark focus failures.
Linux continues to use its existing exact-window public API. Port only the
applicable macOS batching guidance into the canonical skill: combine known
actions and emit final verification with cleanup in one cell, while observing
new dialogs and menus before acting.
Observation retries also share one native 25-second budget. Previously four
native attempts each received a fresh budget; the SDK's existing read-failure
retry could then repeat all four. Retry complete root-only or empty trees for
lazy toolkit registration, but return failed or incomplete reads immediately.
Preserve the native partial tree and diagnostic, or the X11 fallback when no
tree exists. Direct native consumers receive the first failure rather than
implicitly retrying it. The SDK keeps one retry for short read failures, but
returns deadline-exhausted partial captures without starting another full walk.
The companion [observation design](linux-observation-memory-and-compaction.md)
covers structural compaction and bounded LibreOffice collection.
## Validation
Reproduce the released code before editing. Verify true/false acknowledgement,
failure before dispatch, failure after an externally observed effect, timeout,
and partial scroll progress. Assert actual effect counts and absence of a
second actuator, alongside the model-visible error. Exercise element and
coordinate routes and preserve exact native identity on fallback. Then repeat
MCP → Node REPL → SDK input, focus restoration and screenshot tests on the
owned Linux VM, with independent application readback.
Compare right and double clicks against coordinate controls. Verify named text
set, insertion and clearing through both the observation and an independent
application state file. Exercise oversized controls, two windows from one
process, GTK3 decorations and GTK4 geometry separately. These GUI checks do not
prove transport-failure semantics: the GTK accessibility bridge acknowledges
its queued callback independently of that callback's return value.
Exercise complete and incomplete trees, unavailable and failed reads, cold
registration recovery, and budget exhaustion. Check both the native wrapper
and actual SDK facade with counted endpoints. Verify that the last valid
minimal tree survives retry exhaustion and each attempt receives only the
remaining budget. Measure actual model waits separately from allocated native
time budgets and provider latency.
Historical task terminal status, evaluator score, unique model responses,
uncached/cached input and output tokens are separate metrics. Fewer native
calls or text characters do not establish lower model cost. Paired real-task
evidence is required before claiming an efficiency or score improvement.

View file

@ -0,0 +1,72 @@
# Linux CUA 输入恢复
[English](linux-cua-input-recovery.md)
Linux 的语义输入回退此前会将分发后的失败视为语义通道不可用。原生动作可能已经
修改应用状态,随后丢失 D-Bus 回应,而点击工具继续发送像素点击。滚动也存在相同
回退;部分点击路径还会忽略 AT-SPI 的 false 确认并报告成功。
使用已发布的 0.20.8 源码复现:动作产生效果后返回错误或触发原有 25 秒超时,
夹具会记录两次效果,分别来自语义动作和像素动作。未产生效果且返回 false 的确认
仍被报告为 “Clicked element”。这是使用受控传输端点、提取实际源码的复现,
不能据此认定所有历史零分任务的原因。
将 macOS 的恢复原则迁移到现有 Linux 实现:输入分发之前可以回退,一旦可能已经
分发,就必须停止自动重放。每次 AT-SPI 尝试局部记录分发状态,覆盖超时和只完成
部分步骤的滚动批次。确认返回 false、失败或超时时,返回输入结果未确认错误;
调用方必须先观察,再决定是否重试。保留安全的分发前回退、精确目标身份和现有
前台保护。
Node REPL → Computer Use facade → Linux 原生驱动的现有调用结构、进程、安装和
权限保持不变。不引入新的公共 API、服务、全局重试策略或特定 provider 的绕过。
模型指引仍来自固定的 Computer Use 主 skill 文件。
文本插入和整体值替换采用同一分发边界。结果不确定的插入不能继续回退为整字段
替换,被拒绝的替换也不能回退为追加。成功写入但没有回读时仍属于未验证结果;
不能自动报告前台升级要求,否则共享 core 会把它解释为投递失败。
可编辑文本独立于 accessible label 保存,清空字段时也保留空值。紧凑观察行省略
重复标签和值、默认状态及主激活动作,结构化元素仍保留全部动作。非默认状态和
次要动作继续可见,确保 revision 比较保留有意义的操作差异。
像素回退保留精确窗口与元素身份。元素边界与窗口取交集,并在前台准备后重新计算
点击点,避免过大的控件误点同进程的其他窗口。GTK3 已提供含客户端装饰的正确屏幕
坐标,不再套用 GTK4 的阴影重建。GTK4 路径单独保留并验证:其 X11 顶层 Screen
范围实际上是窗口局部坐标,因此同进程多窗口只能通过在原生窗口列表和可访问性
窗口列表中均唯一的标题关联。缺失或重复标题继续拒绝。语义主激活仅适用于无修饰
键的单次左击,保留用户请求的右击与双击语义。
独占 Linux 桌面还复现了活动窗口与 core 焦点就绪需要 644–734 毫秒。将现有焦点
确认循环的预算设为一秒;就绪后立即返回,只重试准备过程。动作回调仍只执行一次,
不会重放结果不确定的输入,也不能据此认定历史 benchmark 的焦点失败原因。
Linux 继续使用精确窗口公共 API。仅将适用的 macOS 批处理指引加入固定 skill:
合并已知动作,在一个 cell 中完成最终验证和清理,遇到新对话框或菜单时先观察。
观察重试共享一个原生 25 秒预算。此前四次原生尝试各自获得完整预算,SDK 的
读取失败重试还可能再执行一轮。为处理工具包延迟注册,只对完整但为空或仅有根
节点的树重试;失败或不完整读取立即返回。保留原生部分树和诊断,无树时保留
X11 回退。直接原生调用方收到第一次失败,不再隐式重试。SDK 对短暂读取失败
仍保留一次重试,但耗尽截止时间的部分捕获直接返回,不再启动完整遍历。
配套的[观察设计](linux-observation-memory-and-compaction.zh-CN.md)说明结构
紧凑化和 LibreOffice 有界采集。
## 验证
修改前先复现已发布代码。覆盖 true/false 确认、分发前失败、独立观察到效果后的
失败、超时和滚动部分进度。除模型可见错误外,还断言真实效果次数和第二执行器
未触发。覆盖元素与坐标路径,确认回退保留精确原生身份。随后在独占 Linux VM
重跑 MCP → Node REPL → SDK 输入、焦点恢复和截图测试,并独立回读应用状态。
将右击和双击与坐标控制组比较。通过观察结果及独立应用状态文件验证有标签文本框
的设置、插入、清空。分别覆盖超大控件、同进程双窗口、GTK3 装饰和 GTK4 几何。
这些 GUI 验证不能证明传输故障语义:GTK 可访问性桥会在回调排队后确认,不依赖
回调本身的返回值。
覆盖完整与不完整树、不可用与失败读取、延迟注册恢复及预算耗尽。用计数端点验证
原生包装和实际 SDK facade。确认重试耗尽后保留最后一份有效最小树,每次尝试
只能使用剩余预算。模型实际等待、分配的原生时间预算和 provider 延迟分别度量。
历史任务终态、评测得分、唯一模型响应数、未缓存/缓存输入和输出 token 是独立
指标。原生调用数或文本字符数减少不能证明模型成本下降;宣称效率或得分提升前
必须取得成对真实任务证据。

View file

@ -0,0 +1,94 @@
# Linux input delivery without model-selected modes
[English](linux-input-delivery.md) | [简体中文](linux-input-delivery.zh-CN.md)
The Linux Computer Use workflow keeps explicit process/window targeting and
current element tokens. The model should choose the target and action; the
runtime should choose a semantic operation or guarded native input.
## Scope
Based on main after PR #11829 merged at
`ba0ddcd0b7d077a66d803898706210ae8b02d910`:
1. Default Linux Computer Use actions permit native focus preparation. Preserve
explicit delivery overrides for existing programmatic callers and Windows.
2. Recover a failed focus preparation only before dispatching input. Never retry
an action after its callback has started, including errors or uncertain results.
3. Propagate the selected desktop's environment in the benchmark launcher.
4. Explain the existing 10-second Node REPL yield default and verify screenshot
forwarding with the current skill.
App handles on Linux, audio support and shell policy are excluded. The follow-up
PR includes the driver/SDK 0.20.8 and Node REPL 0.1.5 release metadata, installers,
consumer pins and complete-package verification. Creating the PR does not run
the production release.
The complete Linux workflow lives in the fixed `computer-use/SKILL.md` entrypoint.
OSWorld extracts that single npm package member and injects it into the user
message; Linux instructions must not depend on reading another skill resource.
## Ownership and authorization
The JavaScript facade selects the default using the connected driver's platform,
not the Node host OS. It passes the existing foreground permission ceiling to the
native call, so the native authorization layer sees the possible focus change.
Do not change the native interpretation of a missing delivery field: bounded
manifests currently interpret it as background-only.
Native Linux keeps its existing semantic and target-addressed routes. Where a
route needs global X11 input, it prepares and verifies the exact window first.
The existing process-wide desktop action coordinator serializes physical input.
There are no new runtime processes, executables, dependencies, permissions or
protocols.
Explicit calls to getPlatform refresh the platform choice. A replaced native
session invalidates any cached platform used for defaults. Default discovery is
internal and does not add a model tool round or another desktop observation.
## Recovery boundary
The focus-preparation callback is separate from the input callback. Only the
former may run again, within a finite deadline and attempt bound. Success,
partial delivery, timeout after dispatch and an input callback error all end the
operation without replay. When focus must be temporarily moved into the target,
restore the previous active window and core focus afterwards. When the target
already holds focus, retain widget or dialog focus changes made by the action;
restoring the old child focus would undo clicks and keyboard navigation.
When restoring another window, wait for its activation and focus transition
before restoring its exact child focus. This confirmation has the same bounded
timeout as preparation; if the window manager refuses restoration, the existing
best-effort core-focus restoration still runs. No input callback is replayed.
The historical active=0 failure needs an X11 reproduction before weakening or
changing the active-window guard. An absent EWMH window manager and a delayed or
refused activation by an existing window manager are different cases.
## Launcher boundary
The benchmark launcher must use the actual selected GUI session's DISPLAY,
XAUTHORITY, DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR (and Wayland display when
applicable). Do not guess :0 or copy an unrelated session's environment. Carry
these through the MCP process and its Node kernel. Missing display selection
fails before MCP configuration is rewritten. The launcher does not probe GUI
availability: an inaccessible display or bus still fails during driver use.
The driver discovery error alone does not repair stripped launcher environment.
The actual launcher changes live in OSWorld-V2, based on
`f4d1d85929c81c1e345a3c8404bde22fec6798f3`. Both Codex and Claude setup paths
copy only the selected desktop variables into the node-repl MCP configuration;
explicit MCP environment values take precedence. The service's user-specific
D-Bus session address is also supplied by its existing systemd setup.
## Validation
Use a fake typed driver to prove platform-aware defaults, explicit override
compatibility, cancellation and no mutation replay. Use a Linux X11 fixture to
observe real focused target and input count, with delayed activation, refused
activation and callback errors. Verify existing sparse token/truncation fixes on
that native build. Compare launcher parent/MCP/kernel values without printing
secrets. Exercise immediate and delayed MCP images as image blocks and a cell
longer than one second with the default yield.
Report native tests separately from protocol tests. No aggregate score or token
improvement is claimed until the same benchmark cases are rerun.

View file

@ -0,0 +1,50 @@
# Linux 输入投递不再由模型选择模式
[English](linux-input-delivery.md) | [简体中文](linux-input-delivery.zh-CN.md)
Linux Computer Use 工作流保留显式进程、窗口定位和当前元素 token。模型选择目标与动作,运行时选择语义操作或经过校验的原生输入。
## 范围
基于 PR #11829 合并后的 main:`ba0ddcd0b7d077a66d803898706210ae8b02d910`。
1. Linux Computer Use 动作默认允许原生焦点准备;保留已有程序调用和 Windows 的显式投递覆盖。
2. 仅在发送输入之前恢复失败的焦点准备。输入回调开始后,错误或不确定结果均不重放动作。
3. 在 benchmark launcher 中传递所选桌面的环境。
4. 解释 Node REPL 既有的 10 秒 yield 默认值,并用当前 skill 验证截图转发。
Linux App handle、音频能力和 shell 策略不在范围内。后续 PR 包含 driver/SDK 0.20.8 与 Node REPL 0.1.5 的发布元数据、安装器、使用方版本和完整包验证。创建 PR 不会执行生产发布。
Linux 完整工作流直接放在固定的 `computer-use/SKILL.md` 入口中。OSWorld 只从 npm 包提取这一成员并注入 user message,因此 Linux 指导不能依赖再读取一个 skill resource。
## 归属与授权
JavaScript facade 根据所连接 driver 的平台选择默认值,而非 Node 宿主系统。它向原生调用传递既有 foreground 权限上限,让授权层看到可能发生的焦点切换。不修改原生层缺省 delivery 字段的解释:bounded manifest 当前将其理解为仅允许 background。
Linux 原生层保留既有语义路径和目标寻址路径。需要全局 X11 输入时,先准备并核验精确窗口。既有进程级桌面动作协调器负责串行物理输入。不增加运行时进程、可执行文件、依赖、权限或协议。
显式调用 getPlatform 会刷新平台选择。原生 session 替换后,用于默认值的平台缓存失效。内部发现不会增加模型工具轮次或桌面观察。
## 恢复边界
焦点准备与输入回调分离。仅准备阶段允许在有限总时限和次数内重试。成功、部分投递、投递后超时或回调错误都结束该操作,不重放输入。需要临时切入目标时,结束后恢复原活动窗口和 core focus。目标原本已聚焦时,保留动作造成的控件或对话框焦点变化;恢复旧子控件会撤销点击和键盘导航。
恢复其他窗口时,先等待它完成激活与焦点切换,再恢复精确子控件,避免窗口管理器覆盖恢复结果。确认阶段使用与准备阶段相同的有界 timeout;窗口管理器拒绝恢复时,仍执行既有 best-effort core-focus 恢复。输入回调不重放。
在削弱或修改 active-window 校验前,历史 active=0 错误需要 X11 复现。不存在 EWMH 窗口管理器,与既有窗口管理器延迟或拒绝激活,是不同情况。
## Launcher 边界
Benchmark launcher 必须使用实际所选 GUI session 的 DISPLAY、XAUTHORITY、DBUS_SESSION_BUS_ADDRESS、XDG_RUNTIME_DIR,以及适用时的 Wayland display。不猜测 :0,也不复制其他 session 的环境。变量应传入 MCP 进程及其 Node kernel。缺少 display 选择时,在改写 MCP 配置前失败。Launcher 不预探测 GUI 可用性;不可访问的 display 或 bus 仍在使用 driver 时失败。
仅改善 driver 的发现错误信息不能修复 launcher 丢失环境的问题。
实际 launcher 修改在 OSWorld-V2 仓库中,基线为 `f4d1d85929c81c1e345a3c8404bde22fec6798f3`。Codex、Claude 两条 setup 路径仅向 node-repl MCP 配置复制所选桌面变量,显式 MCP 环境优先。既有 systemd setup 同时提供服务所属用户的 D-Bus session 地址。
## 验证
使用 typed driver 替身验证按平台选择的默认值、显式覆盖兼容性、取消和不重放修改。使用 Linux X11 fixture 观察真实焦点目标和输入次数,覆盖激活延迟、拒绝及回调错误。在同一原生构建上验证既有 sparse token 和截断修复。
比较 launcher 父进程、MCP 与 kernel 的环境值,不打印秘密。验证立即和延迟返回的 MCP 图片均为 image block,以及超过一秒的 cell 在默认 yield 下的表现。
原生测试与协议测试分别报告。在相同 benchmark case 重跑之前,不宣称总体得分或 token 改善。

View file

@ -0,0 +1,144 @@
# Linux observation memory and compaction
[简体中文](linux-observation-memory-and-compaction.zh-CN.md)
## Scope
Extend the existing Linux recovery candidate in exactly two areas: compact
accessibility presentation and bounded LibreOffice observation resource use.
App APIs, clipboard and OSWorld code are outside this change. Prepare SDK/driver
0.20.9 with synchronized version pins; publishing remains a separate action.
The SDK-to-native AT-SPI topology, permissions and installation remain unchanged.
## Compact presentation
The previous candidate shortened individual rows, but did not migrate the
structural projection present in macOS 0.20.6. Linux also built revision text
from actionable nodes alone, losing passive status and instruction text.
Retain observed passive context, then flatten empty layout containers, remove
duplicate parent/child labels, and merge adjacent passive text under the same
parent. Preserve window, dialog, menu, list, table and document boundaries,
web-content boundaries, focus, selection, disabled state and all actionable
nodes. Use iterative projection so deep trees do not consume the call stack.
Native indices, identities and the structured action list remain intact.
Revision comparison includes the retained static text and editable empty values.
Apply projection at the model observation boundary; internal browser setup and
consent matching retain original static-label boundaries.
## Memory investigation
The original Task035 workbook exposes 1,073,741,824 table children and the
`manages-descendants` state. A controlled single `GetChildren` request increased
LibreOffice RSS by 107.75 MiB in 24.14 seconds (4.45 MiB/s), while the requesting
process stayed flat. The supervisor stopped the owned process at its soft limit;
no OOM was needed to reproduce the growth. This is a direct AT-SPI reproduction,
not proof of the historical sandbox reboot cause.
The instrumented b30 candidate also reproduced growth through real SDK calls:
all three observations returned X11 fallback, while the trace stopped at the
table GetChildren with no reply. During the third call LibreOffice grew at
4.52 MiB/s and Node RSS stayed flat. Returning from the caller does not cancel
provider-side enumeration.
For ordinary nodes, replace whole-child arrays with `ChildCount` and lazy
`GetChildAtIndex`. Keep one next-sibling cursor per ancestor; check node, depth
and time limits before requesting a child. Preserve the existing preorder and
shared actionable-index calculation in snapshots and actuators. A depth limit
reads metadata without expanding children; a failed child request marks the capture incomplete. The fix does not
disable Calc accessibility or allocate a complete array and truncate afterward.
Indexed access alone proved insufficient: three passes over the same 512 real
cells each completed in about 2.8 seconds, but each retained another 3.3–3.4 MiB
in LibreOffice and some cells received new AT-SPI object paths on every pass.
AT-SPI explicitly says clients should not enumerate `ManagesDescendants` nodes.
Do not expand these virtual children; retain the table and the rest of the UI,
and report `managed_descendants_omitted` as a bounded capture. This limitation
preserves identities for captured controls without claiming complete sheet
content. Use screenshots for omitted content. No application-specific process
restart or event-driven accessibility subsystem is introduced.
## Ordinary-layout collection
The ordinary Calc window also exposed a large hidden menu tree. An independent
bounded census found 1,861 nodes, of which 1,638 were not showing, and 11,188
explicit D-Bus calls without errors. The diagnostic final3 SDK completed 770
nodes before its 25-second deadline; 752 were not showing. No individual call
timed out. This was cumulative traversal work, not a stuck LibreOffice method.
Native timed wrappers include peer-to-peer traffic and are not a count of bus
messages; the independent census has a separate sequential-call timing scope.
Read role, state and supported interfaces together, then omit hidden native
menu nodes and their subtrees before reading names, details or children. Keep
showing, focused, selected or expanded menus, and do not apply the rule within
web content or when visibility is unknown. A menu opened by the user is read
on the next observation. Report `hidden_menu_subtrees_omitted` as an explicit
bounded capture, preserving identities only for controls actually captured.
Omitted commands consume no actionable index; tokens resolve by captured AT-SPI
identity so opening a menu does not retarget an existing token by row number.
Collection now receives the snapshot's absolute deadline, including setup and
the subsequent bounds phase. Keep accumulated nodes and bounds outside the
cancelled futures so a slow later request cannot discard all prior work.
Deadline exhaustion remains an incomplete read and cannot retain stable
revision tokens. The native 25-second maximum is unchanged. The facade returns
`walk_deadline_reached` and `element_bounds_timeout` captures without its usual
automatic read retry; short read failures retain one retry. Real fault injection
showed that retrying an exhausted capture otherwise takes about 51 seconds for
two native reads. A later explicit observation can recover normally.
## Validation
Compare compact output against the same complete captured tree, checking
retained content and actionable identities as well as row/character reduction.
Exercise static-text changes, no-change revisions, field clearing, table rows,
web boundaries, current tokens and deep layouts. Run real SDK observations and
independent application readback on the owned Linux desktop.
Measure LibreOffice and the SDK process before, during and after single and
repeated observations; retain raw samples and operation status. Compare the
unchanged baseline and candidate against the same document. Do not infer
provider token costs or historical OOM causality from shorter text or a timeout.
The memory-only candidate (`6babbd96`) passed four full-screen observations,
but three ordinary-layout observations timed out at 25.762, 25.042 and 25.048
seconds. This exact-binary comparison, followed by the instrumentation above,
established the separate hidden-menu traversal problem.
The ordinary-layout repair (`d4ba57b6`) returned useful Task035 trees in 11.570,
10.989, 13.454 and 10.815 seconds, with 157/157/190/157 actionable elements.
All four reached the real table and retained its token; the unchanged second
read returned `no_change`. File activation exposed 33 additional menu labels,
and Escape restored the initial menu-label set. Both intentional omission
reasons were reported, with `captureReadComplete=true` and `captureComplete=false`.
Continuous samples over 68.986 seconds showed LibreOffice RSS between 340644
and 340652 KiB, with a maximum sampling interval of 0.163 seconds. This is
bounded real-application acceptance, not a long-duration leak or benchmark test.
The same native passed a real GTK comparison: legacy tree text stayed
byte-identical, control depth fell from 10 to 2, static instructions survived,
and token click, field replacement/readback and no-change/token stability all
passed. Revision text grew from 352 to 554 characters because the baseline
omitted passive information; this is not a measured token-cost reduction.
Controlled AT-SPI providers verified completed node and bounds preservation,
continued traversal after a failed child, and no descendant enumeration after
a failed state read. The old facade made two reads in 50.785 seconds for the
metadata deadline and 50.965 seconds for the bounds deadline; these are
before-fix retry measurements, not the intended latency of 0.20.9.
The final 0.20.9 package (`3554fe81` native, `f0ba1e38` facade) repeated the
metadata deadline in 25.833 seconds with exactly one native snapshot, retaining
26 controls and issuing no bounds requests after collection expired. A fresh
ordinary maximized Calc run returned 157 controls in 11.084 seconds, then
`no_change` in 10.492 seconds, with the same table token and both bounded-capture
reasons. LibreOffice RSS stayed at 340228 KiB over 23.471 seconds of samples,
with a maximum interval of 0.153 seconds. This final smoke used the packed
0.20.9 SDK and verified native, JavaScript, package and canonical skill hashes.
Final version checks: Linux unit tests 294 passed/4 ignored; SDK tests 164
passed/6 environment-dependent cases skipped; canonical skill 7 passed;
release workflow tests 68 passed/1 skipped. The release build and package
verifier passed pack, publish dry-run, checksum-based clean installation,
canonical skill byte comparison and native loading (60 tools, revision support).
Node REPL remains 0.1.5. No release was published by these checks.

View file

@ -0,0 +1,112 @@
# Linux 观察内存与紧凑化
[English](linux-observation-memory-and-compaction.md)
## 范围
在现有 Linux 第二轮候选上只补两项:可访问性树紧凑展示、LibreOffice 观察资源限制。
App API、剪贴板和 OSWorld 代码不在范围内。补齐 SDK/driver 0.20.9 及其版本引用;
正式发布另行执行。SDK 到原生 AT-SPI 的调用结构、权限和安装方式保持不变。
## 紧凑展示
此前候选只缩短了单行字段,没有迁移 macOS 0.20.6 已包含的结构投影。
Linux 的 revision 还只包含可操作节点,导致静态状态和说明文本丢失。
保留观察到的静态上下文,再折叠空布局容器、去掉父子重复标签、合并同一父节点下
相邻的静态文本。保留窗口、对话框、菜单、列表、表格和文档边界,区分网页正文与
应用界面,保留焦点、选择、禁用状态及全部可操作节点。投影采用迭代实现,避免深树
消耗调用栈。原生索引、身份和结构化动作列表不变。revision 比较包含保留下来的
静态文字和可编辑字段清空后的空值。紧凑化仅用于模型观察输出;浏览器内部设置和
授权提示的精确匹配仍使用原始静态标签边界。
## 内存调查
Task035 原始工作簿的表格暴露 1,073,741,824 个子项,并声明 `manages-descendants`。
受控单次 `GetChildren` 请求使 LibreOffice RSS 在 24.14 秒内增加 107.75 MiB,
拟合速率 4.45 MiB/s,请求进程内存不变。达到软限制后主动停止独立实例,无须真正
触发 OOM 即可复现增长。这是直接 AT-SPI 请求的复现,不是历史沙箱重启根因的证明。
增加请求日志的原 b30 候选也通过实际 SDK 调用复现了增长:三次观察均退回 X11,
日志停在表格 GetChildren 且未返回;第三次调用期间 LibreOffice 约增长 4.52 MiB/s,
Node RSS 不变。调用方返回并不能取消应用已经开始的全量枚举。
普通节点改用 `ChildCount` 和惰性的 `GetChildAtIndex`,每层只保存下一兄弟游标;在请求
子节点之前检查节点、深度和时间预算。快照与动作共用前序遍历及可操作索引计算。达到
深度限制时只读元数据,不展开子项;子项请求失败时标记捕获不完整。修复不禁用
Calc 可访问性,也不会先分配完整数组再截断。
实测证明仅按索引读取仍不够:对相同 512 个真实单元格连续读三轮,每轮约 2.8 秒
返回,但 LibreOffice 每轮仍保留约 3.3–3.4 MiB 内存,部分单元格每次产生新的 AX
对象路径。AT-SPI 明确说明不应枚举 `ManagesDescendants` 节点的子项。因此不展开
这类虚拟子项,保留表格和其他界面,并以 `managed_descendants_omitted` 标记有界
观察;已捕获控件仍保留稳定身份,但不声称包含完整工作表。缺失内容用截图查看。
不引入应用重启或新的可访问性事件处理系统。
## 普通布局采集
普通 Calc 窗口还暴露了庞大的隐藏菜单树。独立有界普查读到 1,861 个节点,其中
1,638 个未显示;11,188 次明确的 D-Bus 请求均无错误。带计时的 final3 SDK 在
25 秒截止前完成 770 个节点,其中 752 个未显示,没有单次调用超时。因此剩余
问题是大量正常遍历工作累积,不能归因为 LibreOffice 的某个方法卡死。
原生计时包含点对点调用,计时包装次数不是总线消息数;独立普查的串行请求统计
也不能直接当作原生并发遍历耗时。
并发读取角色、状态和接口后,在名称、详情和子项读取之前跳过隐藏原生菜单及其
子树。保留已显示、聚焦、选中或展开的菜单;不对网页正文或未知可见性套用规则。
打开菜单后的下一次观察会读取其内容。以 `hidden_menu_subtrees_omitted` 明确标记
有界捕获,仅对实际读到的控件保留身份。
省略的命令不占可操作索引;token 按捕获的 AT-SPI 身份解析,打开菜单不会因行号
变化把已有 token 指向其他控件。
采集共享整个快照的绝对截止时间,包含准备和后续坐标阶段。累计节点和坐标保存在
可取消 future 之外,后续慢请求超时不会抹掉先前结果。时间预算耗尽仍是读取不完整,
不能保留稳定 revision token。原生 25 秒上限保持不变。facade 遇到
`walk_deadline_reached` 或 `element_bounds_timeout` 时直接返回,不再自动重读;
短暂读取失败仍保留一次重试。真实故障注入发现,对耗尽预算的捕获自动重试会产生
两次原生读取、约 51 秒等待。后续显式观察仍可正常恢复。
## 验证
将紧凑输出与同一份完整捕获树比较,同时检查必要内容、可操作身份以及行数和字符
变化。覆盖静态文本变化、无变化 revision、字段清空、表格行、网页边界、当前 token
和深层布局。在独占的 Linux 测试桌面执行真实 SDK 观察和独立应用状态回读。
采集单次及重复观察之前、期间、之后的 LibreOffice 与 SDK 进程内存,保留原始采样
及操作状态;基线和候选使用相同文档。不能从文本变短或调用超时推断模型计费下降
或历史 OOM 根因。
只修复内存的候选(`6babbd96`)通过四次全屏观察,但三次普通布局观察分别在
25.762、25.042、25.048 秒超时。这组精确二进制对照及上文计时验证,确认了
独立的隐藏菜单遍历问题。
普通布局修复版(`d4ba57b6`)在同一 Task035 文档上四次分别耗时 11.570、10.989、
13.454、10.815 秒,返回 157/157/190/157 个可操作元素。每次均到达真实表格,表格
token 保持不变;第二次未变化读取返回 `no_change`。点击 File 后出现 33 个新增
菜单标签,Escape 后恢复初始菜单标签集合。两项主动省略均明确报告,
`captureReadComplete=true`、`captureComplete=false`。连续 68.986 秒采样中,
LibreOffice RSS 在 340644–340652 KiB 之间,最大采样间隔 0.163 秒。
这是有界的真实应用验收,不是长期泄漏压测或完整 benchmark。
同一 native 的真实 GTK 对照也通过:legacy 树逐字一致,控件深度从 10 降到 2,
静态说明保留,token 点击、字段替换读回、无变化及 token 稳定性均正常。
revision 字符从 352 增至 554,因为基线遗漏了静态信息;这不是 token 成本下降
的测量结论。
受控 AT-SPI provider 验证了已完成节点和坐标保留、单个子项失败后继续读取后续
兄弟节点、状态读取失败后不枚举子项。旧 facade 在元数据超时场景读取两次,耗时
50.785 秒;坐标超时场景读取两次,耗时 50.965 秒。这些是修复前的重试测量,
不是 0.20.9 的预期延迟。
最终 0.20.9 包(native `3554fe81`、facade `f0ba1e38`)在同一元数据超时场景
耗时 25.833 秒,仅产生一次原生快照,保留 26 个控件,采集超时后没有发起坐标
请求。新的普通最大化 Calc 验证在 11.084 秒返回 157 个控件,随后在 10.492 秒
返回 `no_change`;表格 token 相同,两项有界捕获原因均明确报告。23.471 秒采样
中 LibreOffice RSS 固定为 340228 KiB,最大采样间隔 0.153 秒。该最终 smoke
使用打包后的 0.20.9 SDK,并核验了 native、JavaScript、包和固定 skill 的 hash。
最终版本检查:Linux 单元测试 294 通过、4 忽略;SDK 测试 164 通过、6 项依赖环境
的测试跳过;固定 skill 7 通过;发布流程测试 68 通过、1 跳过。release build 和
包验证通过,覆盖 pack、publish dry-run、checksum 校验的全新安装、固定 skill
逐字比较和原生加载(60 个工具、支持 revision)。Node REPL 保持 0.1.5;这些
检查没有执行正式发布。

View file

@ -0,0 +1,150 @@
# Markdown footnote cards and turn sources
[中文](./markdown-footnote-cards.zh-CN.md)
## Problem and decision
Reports cite web pages, files, attachments, knowledge records, and explanatory notes. All resolved Markdown footnotes use the same aggregation mechanism, regardless of ID or content. Numeric, named, Chinese, linked and plain-text notes are supported. The existing knowledge icon remains the default. Hosts can select an image resource for each inline footnote group. The Assistant action footer independently summarizes the current turn’s entries from the Sources panel.
```markdown
Orders follow a shared definition.[^a][^b]
Resource group specifications affect concurrency.[^c]
[^a]: [Order definition](https://example.com/orders 'Knowledge') — Business definition.
[^b]: An explanation without a link.
More detail, preserved in the original definition.
[^c]: [Resource specifications](https://example.com/resources) — Specification details.
```
## Public contract
`WebShellMarkdownCustomization` exposes the optional synchronous, side-effect-free `getInlineFootnoteIcon`. It uses the exported `WebShellFootnoteIconResolver` type and returns the existing `WebShellIconSource` resource URL, or null/undefined for the default. The host needs no React dependency to write these functions.
The function receives a readonly list of `WebShellFootnote` values:
| Field | Meaning |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id: string` | Logical footnote ID as written in its definition, without the message DOM prefix or URL encoding. |
| `number: number` | Footnote number in first-reference order. |
| `definitionMarkdown: string` | Complete definition, including `[^id]:`, multiline content and original URLs. Read directly from the AST source position in the Markdown after `transformMarkdown`. |
| `title?: string` | Text of the first safe link. |
| `summary: string` | Remaining textual content; the whole note for an unlinked definition. |
| `href?: string` | First safe link target, using the existing Markdown URL transformation. |
| `source?: string` | The first safe link's optional title attribute. |
| `image?: string` | First safe thumbnail URL. |
No HAST, DOM or React objects enter the public list. The example produces inline calls with `[a,b]` and `[c]`. Footnotes do not determine the Assistant source count. Each list is deduplicated by ID in first-reference order; distinct IDs sharing a URL remain distinct. Pagination selects content within the group without changing the resolver input. React may render more than once; total callback invocation counts are not guaranteed.
```ts
const markdown = {
getInlineFootnoteIcon: (notes) =>
notes.every((note) => note.href?.startsWith('https://citation.invalid/'))
? '/icons/knowledge.svg'
: '/icons/web.svg',
};
```
Missing callbacks, empty/null results, invalid URLs and thrown exceptions fall back to the default knowledge icon. Custom assets use the composer's monochrome mask and image URL policy (including rejection of SVG data URLs). Inline icons are 16px; footer icons are 14px. Only the default footer knowledge glyph receives the existing 1px optical lift. The host controls only the icon; Qwen owns counts, buttons, hover, keyboard, pagination and links. Host assets must normalize their own viewBox padding, painted size, optical center and stroke weight: equal CSS boxes alone do not imply matching glyphs. The demo footer resource is sized against the Assistant copy glyph; browser acceptance checks painted bounds and visual centroids as well as element geometry.
## Rendering and lifecycle
- Adjacent references, allowing whitespace, form one group within their inline parent. Text, punctuation, block and table-cell boundaries break groups. A single reference also forms a group.
- Pure-text notes have a localized “Footnote n” title, the full explanation and no navigation. Long descriptions are scrollable, including with the keyboard.
- Formula titles and summaries use the TeX annotation from rendered KaTeX once, including in host callback data. The complete definition Markdown and ordinary report formula rendering are preserved.
- Hover, focus and click open the preview. Paging or clicking the trigger pins it until Escape or an outside click. Hover-only previews close after leaving, including after Escape restores focus to the trigger. Restored focus alone does not keep later hover previews open; deliberate keyboard focus and focus inside the preview still do. A page represents one definition.
- The Assistant footer shows “N sources” beside copy, branch and time, using Sources-panel entries explicitly associated with that turn. It follows existing message hover/focus and touch visibility rules. It never substitutes the footnote count, and no separate Markdown footnote summary is rendered.
- Unresolved definitions keep literal references. A definition is removed from the ordinary footer only when every occurrence was converted. References that cannot be converted (for example inside a link) retain their ordinary target and return navigation. Definitions containing nested footnote references also keep the ordinary list, preserving the path to the nested note.
- Definition extraction shares the existing AST pipeline; it does not reparse Markdown. Stable component identities preserve open cards through streaming updates. Message instances keep isolated DOM anchor namespaces.
- Markdown copy and static document export preserve standard footnotes. Custom `components.sup` continues to opt out of aggregation. Advanced-table copy keeps original reference text.
## Current-page content slot
`mountFootnotePreview(container, info)` optionally replaces only the current page's source label, title, description and thumbnail. Qwen retains grouping, trigger icons/counts, popup placement, hover/pin/Escape behavior, keyboard navigation and the pager. This slot serves inline footnote previews; the Sources footer uses its own source list. Omitting it, declining with null/undefined, or synchronously failing restores the default page.
The framework-neutral mount function receives a connected, visible HTML container and `WebShellFootnotePreviewInfo`: the complete readonly `footnotes` list, current `footnote`, zero-based `index`, localized `title`, resolved `sourceLabel`, and `sourceLink` (a Qwen-owned HTMLElement). Metadata remains plain data without AST/React objects. The DOM element is a presentation handle: place it in the custom layout without replacing its children. Qwen renders the current source link into it through the existing `components.a` path, including host interception and ordinary safe-link behavior. Unlinked notes expose a non-navigable title.
Return a `WebShellFootnotePreviewHandle` with synchronous `update(info)` and `dispose()` methods. Mount runs when the popup opens. Paging and streamed data normally invoke update without recreating the host view. Returning to a footnote that this mount function previously declined disposes the active view and retries mount; another decline restores the default page. Declines are tracked by footnote ID within the open popup, reset when the mount function changes, and cleared when a retry succeeds. Closing/unmounting, replacing the mount function or falling back disposes the view. React StrictMode may mount/dispose more than once; each successful mount has one matching cleanup. Hosts should keep the mount function stable, handle their own asynchronous errors, and clean up resources if mounting throws before a handle is returned. Synchronous mount/update/cleanup errors are isolated from the report; changing page/data or the mount function can retry a failed page.
Custom content stays in the shell portal and a bounded scroll region. DOM/SolidJS implementations need no React element factory. A host that mounts Solid must retain its reactive owner as required by Solid and return its update/dispose operations. This slot neither changes `components.sup` opt-out priority nor runs for static document footnotes.
The demo offers default and custom content modes; custom content uses a plain DOM mount, places the Qwen-managed source link in its layout and updates on the built-in pager. Unit and browser checks cover payload isolation, current-page/list consistency, mount/update/dispose, fallback/recovery, preserved link interception, focus/paging, streaming and static export.
A minimal DOM host can use the slot as follows:
```js
const markdown = {
mountFootnotePreview(container, initial) {
const summary = container.ownerDocument.createElement('p');
const update = (info) => {
summary.textContent = info.footnote.summary;
container.replaceChildren(info.sourceLink, summary);
};
update(initial);
return {
update,
dispose() {
container.replaceChildren();
},
};
},
};
```
## Host links and scope
Card titles use the existing host `components.a` renderer. Internal reference/backreference navigation stays built in. A normal HTTP(S) link opens normally. A host may encode private locator fields in a sentinel such as `https://citation.invalid/dataworks-knowledge#...`, validate and resolve it on click, and open its own panel. Web Shell neither interprets these fields nor builds OpenCode business URLs. Without a host resolver, the sentinel remains non-navigable. The demo uses fixed fixtures, ordinary IDs, two different inline asset icons, an independent footer asset and an illustrative host panel.
Aggregation is not limited to sentinel links or any business source format. A footnote records what the report cites, not proof of retrieval or entailment. Session Sources provide the canonical data for the separate turn source control. This change adds no MCP, Core citation protocol, metadata fetches or provider-specific resolver.
## Turn source footer
The footer is a view of the same registered sources and attachment fallback entries used by the Sources panel, filtered to explicit associations with the current user turn. Reusing an existing source in another turn counts in both turns, while each turn deduplicates its entries. Only the final Assistant message of a turn displays the control. An empty or unavailable source set has no control; footnotes never fill the gap.
`environmentPanel.items` controls panel sections only. Omitting both `sources` and `attachments` hides that panel section; the Assistant source footer remains enabled. Its source and attachment metadata still load under the existing capability, session and owner guards. This lets hosts hide the built-in section while using their own Sources panel. The panel configuration is not a global source-feature opt-out.
Frozen paginated history omits the turn source footer, like other turn-level outputs: a page can contain only part of a turn and cannot certify its complete source count. Returning to the live transcript restores the footer and its source-opening action.
Associations come from successful top-level `record_source` records, user-message attachment references, or explicit host `sourceReferences` containing session ID, user-turn ID and registered source ID. The selector intersects references with currently available panel entries, excluding failed/pending/cancelled tool registrations, unknown/deleted sources and unrelated turns. Source IDs from tool output are preferred; normalized locators are a fallback for forked histories whose source IDs were rehashed. Workspace-file fallback requires matching workspace roots. No creation/update timestamp or snapshot-revision inference is used. Manual/hook/client registrations without a turn reference remain session-wide panel entries; this change introduces no Core persistence protocol or claim of automatic actual-use tracking.
At the Web Shell customization level, `getAssistantSourcesIcon` receives the complete readonly `WebShellSource` list for the turn and returns a resource URL, with the same default/URL policy as other host icons. `WebShellSource` distinguishes registered `SessionSource` entries and unregistered attachment entries already visible in the panel. This replaces `markdown.getAssistantFootnoteIcon`; it is intentionally separate from Markdown customization.
Hover/focus/click opens a scrollable read-only source list. Selecting a row follows the existing Sources-panel preview action. The footer cannot add, edit or remove sources. Embedded read-only transcripts receive sources, attachment entries, session identity, explicit source references and an open callback from their host. Session/workspace owner guards and existing source preview rules remain authoritative; a foreign or stale source set must never be borrowed to populate a turn.
Tail-only streaming text updates reuse the last committed source associations, preserving completed turns’ source-list identities. Inventory, host references, session/workspace changes and structural transcript updates recompute the associations. Publishing the cache after commit prevents StrictMode replays or abandoned renders from replacing the committed value.
Acceptance covers source counts independent of footnote counts, cross-turn reuse, attachment deduplication, failed/unknown/deleted registrations, fork-locator fallback, explicit host associations, owner isolation, source preview handoff, independent icon customization, hover/focus and ordinary footnote behavior.
Host configuration example (the user-message ID identifies the turn; it is not the daemon prompt ID):
```js
const shellOptions = {
getAssistantSourcesIcon: (entries) =>
entries.every(
(entry) => entry.type === 'source' && entry.source.kind === 'link',
)
? '/icons/web-sources.svg'
: '/icons/sources.svg',
sourceReferences: [
{
sessionId: 'session-id',
turnId: 'user-message-id',
sourceId: 'registered-source-id',
},
],
markdown: {
getInlineFootnoteIcon: () => '/icons/footnote.svg',
},
};
```
For `WebShellTranscript`, provide `sources`, optional `sourceAttachments`, `sourceSessionId` and `onSourceOpen` as well: a read-only transcript has no live Sources-panel loader. The primary App uses its existing guarded panel state and preview callbacks. Attachment inventory refreshes independently of panel visibility, retaining owner checks and streaming throttling, so unregistered attachments also appear without opening the panel. Missing source data stays empty, including other panes that do not supply their own source state; the primary session's entries are never used as a fallback for another pane.
## Implementation and verification
Changes are limited to Web Shell customization types/exports, the existing Markdown AST transform, footnote cards, shared Sources lists and attachment loading, Assistant footer wiring, demo and tests. There are no open design questions.
Focused tests verify IDs, multiline original text after transforms, safe parsed fields, exact footnote groups and per-turn source lists, same-URL distinct notes, callback independence/fallback, default/custom sizing, streaming, incomplete definitions, partial conversion, host links, copy and static export. Development and production browser E2E cover grouping, scrollable descriptions, paging persistence, message hover, keyboard/touch, themes, viewport/portal boundaries and cross-message isolation. The development demo additionally verifies custom SVG assets and host panel handoff; production acceptance uses the built app. Build and typecheck must pass.

Some files were not shown because too many files have changed in this diff Show more