feat(hooks): add FileChanged notifications for local sessions (#13969)

* feat(hooks): add FileChanged notifications for local sessions

* fix(ci): preserve shell gate coverage and trim transcript data

* fix(ci): flush debug prompts and isolate git fixtures

* fix(hooks): respect ACP watcher ownership and skill registration

* test: stabilize hosted recovery and failed spawn fixtures

* fix(ci): budget the complete hosted integration fork

* fix(runtime-broker): accept MariaDB CSI guard metadata

* test: align schema baseline and hook dispatch waits

* fix(ci): allow hosted shared fork to finish all classes

* fix(hooks): preserve file watching across errors and cwd changes

* fix(ci): isolate reference archive extraction per run

* test(ci): keep reference archive checks dependency-free

* fix(hooks): constrain recursive watches and repair CI

* test(web-shell): synchronize verified connection return

* fix(ci): correct runtime broker callback indentation

* test(web-shell): match the managed workspace registration label

Keep the remote workspace registration smoke checks in sync with the service label added by unified workspace navigation. Preserve exact text matching and all registration and replay assertions.

* fix(ci): reduce standalone runtime fixture overhead

* fix(hooks): ignore implicit atomic-write temporary files

* test(managed-agent): recover initial node port conflicts

* fix(hooks): observe parents before discovery anchors disappear

* test(managed-agent): await stream cleanup before recovery teardown

* test(managed-agent): align hosted shell fixture capabilities

* test(managed-agent): let OS choose initial harness port

* fix(ci): restore runner-local Java cache permissions

* test(runtime-broker): use native execution profile paths

* test(managed-agent): bind valid workspace upload probes

* test(managed-agent): reuse the upload probe digest fixture

* test(managed-agent): bind hook fault listeners to available ports

* test(managed-agent): align the native profile path fixture

* test(managed-agent): Wait for projection refusal retries

* ci: Prepare daemon Java cache before Node preflight

* test(managed-agent): check channel model calls after delivery

* fix(hooks): observe explicitly registered symlink targets

* test(managed-agent): separate consumption from relay snapshots

* test(managed-agent): target tamper injection by resource key

* test(managed-agent): exhaust attestation EOF retry in fault gates

* fix(ci): bound RocketMQ release downloads

* test(tui): await the restored assistant before capturing history

* fix(ci): use a checksum-pinned RocketMQ mirror

* fix(ci): provision missing browser test fonts

* fix(ci): quote the font probe environment

* fix(ci): prepare browser fonts without elevation

* test(core): compare complete prune reports without ordering

* fix(ci): repair only runner-local Java tool caches

* test(managed-agent): qualify fixture JDBC templates

* fix: isolate reconnect hooks and cancellation test fixtures

Skip chat initialization so MCP reconnect cannot re-enable file hooks, and keep cancellation fixtures isolated from the background worktree scanner.

* fix(ci): tolerate a missing Java cache helper on resumed releases

Resuming an existing release checks out a tag that may predate the helper, which failed all five Java jobs before setup-java ran.

* fix: finish the main merge for harness deadlines and workflow size

Use the renamed settle-timeout accessor in the merged hosted-harness tests,
and record sdk-java.yml's new size after the resumed-release cache guard.

* style(managed-agent): restore import grouping in merged verification tests

* test(managed-agent): require consumed child result mirrors

* test(web-shell): center repository settings capture

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: yungsen.hys <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-06 21:43:01 +08:00
1 parent e61ad3959e
commit 7cb46d9fed
90 files changed
+4678 -245

No files matched your search

@@ -117,6 +117,10 @@ for (const name of [
while (ports[name] === undefined) {
const port = await pick();
if (port === null) continue;
if (name === 'RMQ_BROKER_PORT' && (port < 3 || port > 65534)) {
release(port);
continue;
}
let kept = true;
if (name === 'RMQ_BROKER_PORT') {
// A companion can repeat a port an earlier role already parked: the
@@ -1330,6 +1330,21 @@ describe('trusted-PR lanes moved off the hosted queue (#13245)', () => {
String(s.uses || '').startsWith('actions/setup-node'),
);
assert.equal(setupNode.if, "${{ runner.environment == 'github-hosted' }}");
const setupJava = build.steps.find((s) =>
String(s.uses || '').startsWith('actions/setup-java'),
);
const cache = setupJava.with.cache;
for (const [environment, expected] of [
['github-hosted', 'maven'],
['self-hosted', ''],
]) {
const actual = cache.startsWith('${{')
? new Function('runner', `return (${cache.slice(3, -2)});`)({
environment,
})
: cache;
assert.equal(actual, expected, environment);
}
});
it('tui-parity keeps setup-node and the pnpm cache off the pool', () => {
+16 -5
View File
@@ -154,11 +154,22 @@ bash .github/scripts/ecs-runner/check-font-baseline.sh
bash .github/scripts/ecs-runner/check-font-baseline.sh --with-metrics
```
The web-shell smoke lane runs the L0 half as `Check host font baseline
(self-hosted)` before installing dependencies, so a drifted host fails in
seconds with an actionable message instead of failing two layout assertions
26 minutes into the suite. L1 stays a manual/host-provisioning check because
it needs a Chromium that the lane has not installed yet at that point.
The web-shell smoke lane is pinned to `ubuntu-latest` to isolate its browsers
from shared-host network changes (run 40915920935 failed before app startup
with `ERR_NETWORK_CHANGED` on ARM64). Its retained `Check host font baseline
(self-hosted)` step is dormant on that routing; should the lane return to a
shared host, that step runs the L0 half before installing dependencies, so a
drifted host fails in seconds with an actionable message instead of failing two
layout assertions 26 minutes into the suite. When L0 fails there, it downloads
the two DejaVu packages using private APT lists, extracts only their font data
into `${XDG_DATA_HOME:-$HOME/.local/share}/fonts/qwen-ci`, refreshes the
runner's font cache, and verifies L0 again. This runs at the runner's existing
privilege level: CI does not elevate or execute the host-provisioning
installer. Healthy hosts require no download or extraction; acquisition
failures and remaining runner-user drift still fail the gate. Before moving
browser jobs back to a shared host, provide network isolation as well as this
font baseline; matching fonts alone does not prevent interrupted module loads.
L1 remains a manual/host-provisioning check requiring an installed Chromium.
## Regression check
@@ -26,6 +26,13 @@ function makeCase({
installed = true,
metrics = BASELINE,
browser = true,
uid = '1000',
repairFamily = false,
rootFamily,
sudoStatus = '0',
aptStatus = '0',
extractStatus = '0',
customDataHome = false,
} = {}) {
const dir = mkdtempSync(join(tmpdir(), 'qwen-font-baseline-test-'));
const bin = join(dir, 'bin');
@@ -33,14 +40,45 @@ function makeCase({
const write = (name, body) =>
writeFileSync(join(bin, name), body, { mode: 0o755 });
write('fc-match', '#!/usr/bin/env bash\nprintf "%s" "$CASE_FAMILY"\n');
write(
'fc-match',
'#!/usr/bin/env bash\nif [ "${CASE_AS_ROOT:-0}" = 1 ] && [ -n "${CASE_ROOT_FAMILY:-}" ]; then printf "%s" "$CASE_ROOT_FAMILY"; elif [ -f "$CASE_DIR/repaired" ]; then printf "DejaVu Sans"; else printf "%s" "$CASE_FAMILY"; fi\n',
);
write(
'fc-cache',
'#!/usr/bin/env bash\nprintf "fc-cache %s\\n" "$*" >> "$CASE_DIR/calls"\n',
'#!/usr/bin/env bash\nprintf "fc-cache %s\\n" "$*" >> "$CASE_DIR/calls"\nif [ "$CASE_REPAIR_FAMILY" = 1 ] && [ -f "${XDG_DATA_HOME:-$HOME/.local/share}/fonts/qwen-ci/truetype/dejavu/DejaVuSans.ttf" ]; then touch "$CASE_DIR/repaired"; fi\n',
);
write(
'apt-get',
'#!/usr/bin/env bash\nprintf "apt-get %s\\n" "$*" >> "$CASE_DIR/calls"\n',
[
'#!/usr/bin/env bash',
'printf "apt-get %s\\n" "$*" >> "$CASE_DIR/calls"',
'[ "$CASE_APT_STATUS" = 0 ] || exit "$CASE_APT_STATUS"',
'if [ "$1" = -o ]; then shift 2; fi',
'if [ "$1" = download ]; then touch fonts-dejavu-core_1_all.deb fonts-dejavu-mono_1_all.deb; fi',
'if [ "$1" = install ] && [ "$CASE_REPAIR_FAMILY" = 1 ]; then touch "$CASE_DIR/repaired"; fi',
'',
].join('\n'),
);
write(
'dpkg-deb',
[
'#!/usr/bin/env bash',
'printf "dpkg-deb %s\\n" "$*" >> "$CASE_DIR/calls"',
'[ "$CASE_EXTRACT_STATUS" = 0 ] || exit "$CASE_EXTRACT_STATUS"',
'[ "$1" = --extract ] || exit 2',
'mkdir -p "$3/usr/share/fonts/truetype/dejavu"',
'printf fixture-font > "$3/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"',
'',
].join('\n'),
);
write(
'id',
'#!/usr/bin/env bash\nif [ "${CASE_AS_ROOT:-0}" = 1 ]; then printf "0\\n"; else printf "%s\\n" "$CASE_UID"; fi\n',
);
write(
'sudo',
'#!/usr/bin/env bash\nprintf "sudo %s\\n" "$*" >> "$CASE_DIR/calls"\nif [ "$CASE_SUDO_STATUS" != 0 ]; then exit "$CASE_SUDO_STATUS"; fi\n[ "$1" = -n ] || exit 2\nshift\nCASE_AS_ROOT=1 exec "$@"\n',
);
write(
'dpkg-query',
@@ -64,10 +102,18 @@ function makeCase({
CASE_DIR: dir,
CASE_FAMILY: family,
CASE_INSTALLED: installed ? '1' : '0',
CASE_UID: uid,
CASE_REPAIR_FAMILY: repairFamily ? '1' : '0',
CASE_ROOT_FAMILY: rootFamily ?? '',
CASE_SUDO_STATUS: sudoStatus,
CASE_APT_STATUS: aptStatus,
CASE_EXTRACT_STATUS: extractStatus,
CASE_METRICS: JSON.stringify(metrics),
GITHUB_ACTIONS: '',
HOME: join(dir, 'home'),
TMPDIR: join(dir, 'tmp'),
RUNNER_TEMP: join(dir, 'tmp'),
XDG_DATA_HOME: customDataHome ? join(dir, 'xdg-data') : '',
CHROMIUM_BIN: browser
? join(bin, 'chromium')
: join(dir, 'no-such-browser'),
@@ -76,9 +122,10 @@ function makeCase({
return { dir, env };
}
function run(script, args, env) {
function run(script, args, env, cwd = join(scriptDir, '../../..')) {
const result = spawnSync('bash', [script, ...args], {
env,
cwd,
encoding: 'utf8',
timeout: 60_000,
});
@@ -169,8 +216,6 @@ test('installer refuses to run as non-root', () => {
...env,
QWEN_FONT_BASELINE_TEST_ALLOW_NON_ROOT: '0',
});
// The test suite itself may not run as root; when it does, skip the guard.
if (process.getuid?.() === 0) return;
assert.equal(result.status, 1);
assert.match(result.stderr, /as root/);
});
@@ -218,3 +263,142 @@ test('installer fails when the host still resolves the wrong family', () => {
assert.match(result.stderr, /fonts-dejavu-core/);
});
});
function runCiFontStep(env, { installer, checker } = {}) {
const workflow = readFileSync(
join(scriptDir, '../../workflows/ci.yml'),
'utf8',
);
const step = workflow.match(
/- name: 'Check host font baseline \(self-hosted\)'[\s\S]*?run: \|-\n([\s\S]*?)(?=\n {6}- name:)/,
);
assert.ok(step, 'the actual CI font step must be present');
const checkout = join(env.CASE_DIR, 'checkout');
const fontScripts = join(checkout, '.github/scripts/ecs-runner');
mkdirSync(fontScripts, { recursive: true });
writeFileSync(
join(fontScripts, 'install-qwen-font-baseline.sh'),
installer ?? readFileSync(installScript),
);
writeFileSync(
join(fontScripts, 'check-font-baseline.sh'),
checker ?? readFileSync(checkScript),
);
const script = join(env.CASE_DIR, 'ci-font-step.sh');
writeFileSync(script, `set -eo pipefail\n${step[1].replace(/^ {10}/gm, '')}`);
return run(script, [], { ...env, GITHUB_ACTIONS: 'true' }, checkout);
}
test('CI leaves a healthy runner unmodified', () => {
withCase({}, (env) => {
const result = runCiFontStep(env);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.calls, '');
assert.match(result.stdout, /default sans-serif = DejaVu Sans/);
});
});
for (const uid of ['0', '1000']) {
test(`CI prepares user fonts without elevation for runner uid ${uid}`, () => {
withCase(
{
uid,
installed: false,
family: 'WenQuanYi Zen Hei',
repairFamily: true,
},
(env) => {
const result = runCiFontStep(env);
assert.equal(result.status, 0, result.stderr);
assert.match(
result.calls,
/apt-get .* download fonts-dejavu-core fonts-dejavu-mono/,
);
assert.match(
result.calls,
/Dir::State::lists=.*\/qwen-fonts\.[^/]+\/lists/,
);
assert.match(result.calls, /dpkg-deb --extract/);
assert.equal(
readFileSync(
join(
env.HOME,
'.local/share/fonts/qwen-ci/truetype/dejavu/DejaVuSans.ttf',
),
'utf8',
),
'fixture-font',
);
assert.match(result.stdout, /default sans-serif = DejaVu Sans/);
assert.doesNotMatch(result.stdout, /::error::/);
assert.doesNotMatch(result.calls, /sudo|apt-get install/);
},
);
});
}
test('CI rejects runner font drift after extracting user fonts', () => {
withCase(
{ family: 'WenQuanYi Zen Hei', rootFamily: 'DejaVu Sans' },
(env) => {
const result = runCiFontStep(env);
assert.equal(result.status, 1);
assert.match(result.calls, /dpkg-deb --extract/);
assert.doesNotMatch(result.calls, /sudo/);
assert.match(result.stdout, /::error::default sans-serif is 'WenQuanYi/);
},
);
});
for (const failure of ['aptStatus', 'extractStatus']) {
test(`CI fails closed when ${failure} fails`, () => {
withCase({ family: 'WenQuanYi Zen Hei', [failure]: '1' }, (env) => {
const result = runCiFontStep(env);
assert.equal(result.status, 1);
assert.doesNotMatch(result.calls, /sudo|apt-get install/);
assert.doesNotMatch(result.stdout, /default sans-serif = DejaVu Sans/);
});
});
}
test('CI keeps fonts in the configured XDG data directory', () => {
withCase(
{ family: 'WenQuanYi Zen Hei', repairFamily: true, customDataHome: true },
(env) => {
const result = runCiFontStep(env);
assert.equal(result.status, 0, result.stderr);
assert.equal(
readFileSync(
join(
env.XDG_DATA_HOME,
'fonts/qwen-ci/truetype/dejavu/DejaVuSans.ttf',
),
'utf8',
),
'fixture-font',
);
},
);
});
test('CI never executes a PR-provided installer', () => {
withCase({ family: 'WenQuanYi Zen Hei', repairFamily: true }, (env) => {
const result = runCiFontStep(env, {
installer: 'printf "head-installer\\n" >> "$CASE_DIR/calls"\nexit 0\n',
});
assert.equal(result.status, 0, result.stderr);
assert.doesNotMatch(result.calls, /head-installer|sudo/);
});
});
test('CI never elevates a PR-provided checker', () => {
withCase({ family: 'WenQuanYi Zen Hei', repairFamily: true }, (env) => {
const result = runCiFontStep(env, {
checker:
'if [ "${CASE_AS_ROOT:-0}" = 1 ]; then printf "privileged-checker\\n" >> "$CASE_DIR/calls"; fi\n' +
readFileSync(checkScript, 'utf8'),
});
assert.equal(result.status, 0, result.stderr);
assert.doesNotMatch(result.calls, /privileged-checker|sudo/);
});
});
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# Only the runner's own cache can be repaired; a configured shared cache
# requires runner provisioning rather than a job changing its permissions.
cache_path="${RUNNER_TOOL_CACHE:?}"
if [ ! -e "$cache_path" ] && [ ! -L "$cache_path" ]; then
exit 0
fi
tool_cache="$(cd -P -- "$cache_path" && pwd -P)"
runner_work="$(cd -P -- "${RUNNER_TEMP:?}" && cd .. && pwd -P)"
if [ "$tool_cache" != "$runner_work/_tool" ]; then
echo "::error::Refusing to change Java cache outside this runner's work directory: $tool_cache"
exit 1
fi
java_cache="$tool_cache/Java_Temurin-Hotspot_jdk"
if [ -L "$java_cache" ] || { [ -e "$java_cache" ] && [ ! -d "$java_cache" ]; }; then
echo "::error::Refusing to change a redirected or non-directory Java cache: $java_cache"
exit 1
fi
trap 'echo "::error::Could not restore the runner-local Java cache; the runner needs permission repair: $java_cache"' ERR
owner="$(id -u):$(id -g)"
mkdir -p -- "$java_cache" 2>/dev/null || sudo -n mkdir -p -- "$java_cache"
chown -hR -- "$owner" "$java_cache" 2>/dev/null || sudo -n chown -hR -- "$owner" "$java_cache"
chmod -R u+rwX "$java_cache" 2>/dev/null || sudo -n chmod -R u+rwX "$java_cache"
+1 -1
View File
@@ -60,7 +60,7 @@
1079 scorecard-monthly.yml
9751 sdk-java-contract-open-prs.yml
9555 sdk-java-flyway-open-prs.yml
155888 sdk-java.yml
159831 sdk-java.yml
3886 sdk-python.yml
5595 security-checks.yml
6777 serve-ab-publish.yml
+23 -3
View File
@@ -695,7 +695,7 @@ jobs:
# runners, so such state persists. Start the shard-owned cache
# empty — and only that exact path, so a runner-exported shared
# cache and lint's already-warmed run-unique home stay untouched.
if [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
rm -rf -- "${COREPACK_HOME}"
fi
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
@@ -1194,7 +1194,7 @@ jobs:
# runners, so such state persists. Start the shard-owned cache
# empty — and only that exact path, so a runner-exported shared
# cache and lint's already-warmed run-unique home stay untouched.
if [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
rm -rf -- "${COREPACK_HOME}"
fi
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
@@ -1992,7 +1992,7 @@ jobs:
# runners, so such state persists. Start the shard-owned cache
# empty — and only that exact path, so a runner-exported shared
# cache and lint's already-warmed run-unique home stay untouched.
if [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
rm -rf -- "${COREPACK_HOME}"
fi
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
@@ -2407,6 +2407,26 @@ jobs:
echo 'font baseline check: skipped (script not present in this checkout)'
exit 0
fi
# A recoverable setup gap should not emit a failure annotation.
if GITHUB_ACTIONS='' bash .github/scripts/ecs-runner/check-font-baseline.sh; then
exit 0
fi
# Keep PR-supplied code at the runner's existing privilege level.
font_stage="$(mktemp -d "${RUNNER_TEMP:-/tmp}/qwen-fonts.XXXXXX")"
trap 'rm -rf -- "${font_stage}"' EXIT
mkdir -p "${font_stage}/lists/partial" "${font_stage}/packages"
apt-get -o "Dir::State::lists=${font_stage}/lists" update -qq
(
cd "${font_stage}/packages"
apt-get -o "Dir::State::lists=${font_stage}/lists" download fonts-dejavu-core fonts-dejavu-mono
)
for archive in "${font_stage}"/packages/*.deb; do
dpkg-deb --extract "${archive}" "${font_stage}/extracted"
done
font_dir="${XDG_DATA_HOME:-${HOME}/.local/share}/fonts/qwen-ci"
mkdir -p "${font_dir}"
cp -R "${font_stage}/extracted/usr/share/fonts/." "${font_dir}/"
fc-cache -f "${font_dir}"
bash .github/scripts/ecs-runner/check-font-baseline.sh
# The build comes from test_build's tree below, not from `prepare`.
@@ -356,6 +356,13 @@ jobs:
if [[ "${RUN_ATTEMPT}" == '1' ]]; then
total=0
infra=0
# The gate workflow's reporter job is always() and fails with
# only the tier-result error, which carries no infrastructure
# signature; excluding it by its failing step keeps a lane's
# infra death classifiable. Filtering the step rather than the
# job keeps the legitimate rerun: when the reporter's own
# runner dies no step has failed, so the job still counts and
# its GitHub-generated annotation still matches.
while IFS=$'\t' read -r job_id check_run_id; do
total=$((total + 1))
annotations=''
@@ -362,9 +362,9 @@ jobs:
release="${RUNNER_TEMP}/managed-agent-reference"
cd "${release}"
sha256sum --check SHA256SUMS.txt
mkdir extracted
tar -xzf "qwen-managed-agent-reference-${RELEASE_VERSION}.tar.gz" -C extracted
bundle="${release}/extracted/qwen-managed-agent-reference-${RELEASE_VERSION}"
extracted="$(mktemp -d "${release}/extracted.XXXXXX")"
tar -xzf "qwen-managed-agent-reference-${RELEASE_VERSION}.tar.gz" -C "${extracted}"
bundle="${extracted}/qwen-managed-agent-reference-${RELEASE_VERSION}"
cd "${bundle}"
sha256sum --check SHA256SUMS
# The upgrade command compares these scripts across releases, so it
+48
View File
@@ -207,6 +207,18 @@ jobs:
# and the post-job save tars that whole shared repository — 14 to 37
# minutes per job on 2026-10-16 — only to lose the reservation race to
# a parallel job and discard the archive.
# Only default _tool caches are candidates for permission repair; the
# helper still checks their canonical runner-local path. Shared caches
# keep their provisioned permissions and use normal setup-java.
- name: 'Restore runner-local Java tool cache'
if: "${{ runner.environment == 'self-hosted' && endsWith(runner.tool_cache, '/_tool') }}"
run: |-
if [ -f .github/scripts/restore-java-tool-cache.sh ]; then
bash .github/scripts/restore-java-tool-cache.sh
else
echo '::warning::this ref predates restore-java-tool-cache.sh; the runner-local Java cache is left as provisioned'
fi
- name: 'Set up Java'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
@@ -809,6 +821,15 @@ jobs:
if: "${{ runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Restore runner-local Java tool cache'
if: "${{ runner.environment == 'self-hosted' && endsWith(runner.tool_cache, '/_tool') }}"
run: |-
if [ -f .github/scripts/restore-java-tool-cache.sh ]; then
bash .github/scripts/restore-java-tool-cache.sh
else
echo '::warning::this ref predates restore-java-tool-cache.sh; the runner-local Java cache is left as provisioned'
fi
- name: 'Set up Java'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
@@ -1818,6 +1839,15 @@ jobs:
if: "${{ runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Restore runner-local Java tool cache'
if: "${{ runner.environment == 'self-hosted' && endsWith(runner.tool_cache, '/_tool') }}"
run: |-
if [ -f .github/scripts/restore-java-tool-cache.sh ]; then
bash .github/scripts/restore-java-tool-cache.sh
else
echo '::warning::this ref predates restore-java-tool-cache.sh; the runner-local Java cache is left as provisioned'
fi
- name: 'Set up Java'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
@@ -2102,6 +2132,15 @@ jobs:
if: "${{ runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Restore runner-local Java tool cache'
if: "${{ runner.environment == 'self-hosted' && endsWith(runner.tool_cache, '/_tool') }}"
run: |-
if [ -f .github/scripts/restore-java-tool-cache.sh ]; then
bash .github/scripts/restore-java-tool-cache.sh
else
echo '::warning::this ref predates restore-java-tool-cache.sh; the runner-local Java cache is left as provisioned'
fi
- name: 'Set up Java'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
@@ -2493,6 +2532,15 @@ jobs:
node-version: '22'
package-manager-cache: false
- name: 'Restore runner-local Java tool cache'
if: "${{ runner.environment == 'self-hosted' && endsWith(runner.tool_cache, '/_tool') }}"
run: |-
if [ -f .github/scripts/restore-java-tool-cache.sh ]; then
bash .github/scripts/restore-java-tool-cache.sh
else
echo '::warning::this ref predates restore-java-tool-cache.sh; the runner-local Java cache is left as provisioned'
fi
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
+6 -1
View File
@@ -563,7 +563,12 @@ for (const key of spec.required ?? []) {
const prompt = promptFor(options, spec);
if (options.printPrompt) {
process.stdout.write(prompt);
await new Promise((resolve, reject) => {
process.stdout.write(prompt, (error) => {
if (error) reject(error);
else resolve();
});
});
process.exit(0);
}
@@ -4,12 +4,14 @@
## Status and scope
- Status: implemented by this change; a verification-only gate, no production code change.
- Date: 2026-11-04
- Status: implemented; enforcement is verification-only. Receipt-helper extraction preserves runtime validation while tightening the ceilings.
- Date: 2026-11-04; updated 2026-11-05
- Scope: `packages/managed-agent-server` — the `persistence` and `runtime-broker` production sources.
- Baseline: `origin/main` `14a5a2d98d`, after the store decomposition (#16090, #16142) and the broker extraction cluster landed.
This resolves the open question left by the [structure review](2026-10-31-managed-agent-structure-review.md): whether to introduce the class-size ratchet gate before P1 (to stop regrowth) or only after the splits land. The two headline god classes are now decomposed — `ManagedAgentStore` is a 1,170-line facade and `ManagedSessionStore` a 357-line facade, and `RuntimeBrokerService` is a 4,253-line facade over ten collaborators — so the gate is introduced **after the splits land**, to lock in those gains. The structure review's acceptance criteria already name this gate: "P1 complete when no class in `persistence` or `runtime-broker` exceeds the agreed size ratchet."
This resolves the open question left by the [structure review](2026-10-31-managed-agent-structure-review.md): whether to introduce the class-size ratchet gate before P1 (to stop regrowth) or only after the splits land. At introduction, the two headline god classes were decomposed — `ManagedAgentStore` is a 1,170-line facade and `ManagedSessionStore` a 357-line facade, and `RuntimeBrokerService` is a 4,270-line facade over nine collaborators — so the gate is introduced **after the splits land**, to lock in those gains. The structure review's acceptance criteria already name this gate: "P1 complete when no class in `persistence` or `runtime-broker` exceeds the agreed size ratchet."
Combining upstream's extraction of provisioning failure handling with the move of the existing `sameReceipt` pure comparison into `RuntimeBrokerSupport` leaves the broker facade at 4,216 lines over ten collaborators. The receipt comparison body and its two acknowledgement call sites remain unchanged; the global and broker-specific ceilings both tighten to 4,216.
## Problem
@@ -19,7 +21,7 @@ The decomposition moved responsibilities out of the god classes, but nothing sto
A single verification test, `ClassSizeRatchetTest` (in the `architecture` package next to `ModuleArchitectureTest`), walks the production source trees of `persistence/src/main/java` and `runtime-broker/src/main/java`, counts source lines per top-level class, and enforces four rules against a pin resource, `class-size-ratchet.properties`:
1. **Global ceiling.** No class exceeds `GLOBAL_MAX_LINES` (4,253). This is the "agreed size ratchet" of the acceptance criteria, seeded at the current largest class (`RuntimeBrokerService`). It only ever lowers.
1. **Global ceiling.** No class exceeds `GLOBAL_MAX_LINES` (4,216). This is the "agreed size ratchet" of the acceptance criteria, seeded at the current largest class (`RuntimeBrokerService`). It only ever lowers.
2. **Per-class ceiling.** Every class listed in the pin must have at most its pinned line count. The pin is seeded at each large class's current size, so a pinned class cannot grow at all.
3. **Large classes must be pinned.** Any class at or above `LARGE_CLASS_THRESHOLD` (800 lines) that is not in the pin fails. A class therefore cannot silently grow into a large one; crossing 800 lines forces a conscious pin at its then-current size.
4. **No stale pins.** Every pin entry must map to an existing production class. Deleting or renaming a class forces its pin to be pruned, keeping the pin honest.
@@ -29,7 +31,7 @@ Line counts count newline characters, matching `wc -l`; `package-info.java` and
### Thresholds and why
- **`LARGE_CLASS_THRESHOLD = 800`.** A round line just below the smallest current large class (802). It captures every current aggregate and facade, and treats "reached 800 lines" as the point where a class becomes large enough to deserve an explicit, reviewed ceiling. Classes below it are free to change, so ordinary edits to small helpers do not trip the gate.
- **`GLOBAL_MAX_LINES = 4253`.** The tightest value that passes today (the current maximum). It is a backstop that no class — pinned or new — may exceed, and it ratchets down as the remaining large classes (`WorkspaceCsiApplicationInventory` 3,329, `ToolPublicationDataStore` 2,709, `JdbcRuntimeBindingRepository` 2,671, `ManagedExtensionRecordStore` 2,170, and the broker facade) are decomposed.
- **`GLOBAL_MAX_LINES = 4216`.** The tightest value that passes today (the current maximum). It is a backstop that no class — pinned or new — may exceed, and it ratchets down as the remaining large classes (`WorkspaceCsiApplicationInventory` 3,329, `ToolPublicationDataStore` 2,696, `JdbcRuntimeBindingRepository` 2,671, `ManagedExtensionRecordStore` 2,170, and the broker facade) are decomposed.
- **Scope is exactly `persistence` + `runtime-broker`.** These are the two modules the acceptance criteria name. The `runtime-*` provider modules (`runtime-http`/`host`/`local`/`external`/`kubernetes`/`ssh`/`static`) are separate top-level modules and out of scope; `contracts`, `control-plane`, `adapters`, and `server` are likewise not ratcheted here.
### Ratchet discipline
@@ -38,7 +40,7 @@ Ceilings only ever go **down**. When a class shrinks, lower its ceiling in the p
## Verification
- The gate passes on the baseline: `ClassSizeRatchetTest` — `Tests run: 1, Failures: 0, Errors: 0` (JDK 21, isolated task repository). Checkstyle (which includes test sources) reports zero violations for the new test.
- Historical baseline verification: `ClassSizeRatchetTest` — `Tests run: 1, Failures: 0, Errors: 0` (JDK 21, isolated task repository). Checkstyle (which includes test sources) reports zero violations for the new test.
- Each rule is mutation-verified with a byte-level backup and sha256-verified restore:
- Lower `ManagedAgentStore`'s ceiling to 100 → fails: "grew to 1170 lines, above its ratchet ceiling 100" (rule 2).
- Remove `ManagedAgentStore`'s pin entry → fails: "reached 1170 lines (>= 800) but is not pinned" (rule 3).
@@ -46,6 +48,8 @@ Ceilings only ever go **down**. When a class shrinks, lower its ceiling in the p
- Lower `GLOBAL_MAX_LINES` to 100 → fails: "… above the global ceiling 100" (rule 1).
- After restore, the gate is green again.
- Receipt-helper extraction acceptance requires the source-size gate and the exact-receipt acknowledgement tests (`RuntimeSshAcknowledgementTest` and `RuntimeBrokerServiceTest`) in CI. The extraction changes method ownership, preserving the comparison body and acknowledgement locking.
## Follow-up
- As each remaining large class is decomposed, lower its ceiling (or prune it) and lower `GLOBAL_MAX_LINES`; the structure-review ledger's acceptance criterion is met when no class exceeds the agreed ratchet.
@@ -4,12 +4,14 @@
## 状态与范围
- 状态:由本改动实现;仅 verification 门禁,不改任何生产代码。
- 日期:2026-11-04
- 状态:已实现;仅由 verification 强制。回执 helper 提取保持运行时校验语义,并收紧上限。
- 日期:2026-11-04;更新于 2026-11-05
- 范围:`packages/managed-agent-server` —— `persistence` 与 `runtime-broker` 的生产源码。
- 基线:`origin/main` `14a5a2d98d`,store 分解(#16090、#16142)与 broker 提取簇落地之后。
本文解决[结构评审](2026-10-31-managed-agent-structure-review.zh-CN.md)留下的开放问题:类体量棘轮门禁是在 P1 之前引入(阻止再长),还是在拆分落地之后才引入。两个头号上帝类现已分解——`ManagedAgentStore` 是 1,170 行门面、`ManagedSessionStore` 是 357 行门面,`RuntimeBrokerService` 是十个协作者之上的 4,253 行门面——因此门禁在**拆分落地之后**引入,以锁住这些成果。结构评审的验收标准早已点名此门禁:"P1 完成=`persistence` 或 `runtime-broker` 中没有任何类超过约定的体量棘轮"。
本文解决[结构评审](2026-10-31-managed-agent-structure-review.zh-CN.md)留下的开放问题:类体量棘轮门禁是在 P1 之前引入(阻止再长),还是在拆分落地之后才引入。引入门禁时,两个头号上帝类已经分解——`ManagedAgentStore` 是 1,170 行门面、`ManagedSessionStore` 是 357 行门面,`RuntimeBrokerService` 是九个协作者之上的 4,270 行门面——因此门禁在**拆分落地之后**引入,以锁住这些成果。结构评审的验收标准早已点名此门禁:"P1 完成=`persistence` 或 `runtime-broker` 中没有任何类超过约定的体量棘轮"。
合并上游运行时供给失败处理逻辑的提取与已有 `sameReceipt` 纯比较函数向 `RuntimeBrokerSupport` 的迁移后,broker 门面为十个协作者之上的 4,216 行。回执比较函数体和两个确认调用位置保持不变;全局和 broker 专属上限都收紧为 4,216。
## 问题
@@ -19,7 +21,7 @@
单个 verification 测试 `ClassSizeRatchetTest`(置于 `architecture` 包、与 `ModuleArchitectureTest` 并列)遍历 `persistence/src/main/java` 与 `runtime-broker/src/main/java` 的生产源码树,按顶层类统计源码行数,并对照 pin 资源 `class-size-ratchet.properties` 强制四条规则:
1. **全局上限。** 任何类不得超过 `GLOBAL_MAX_LINES`(4,253)。这就是验收标准里的"约定体量棘轮",以当前最大类(`RuntimeBrokerService`)作种子,只降不升。
1. **全局上限。** 任何类不得超过 `GLOBAL_MAX_LINES`(4,216)。这就是验收标准里的"约定体量棘轮",以当前最大类(`RuntimeBrokerService`)作种子,只降不升。
2. **按类上限。** pin 中列出的每个类,行数不得超过其钉住值。pin 以每个大类的当前行数作种子,故被钉住的类完全不能再长。
3. **大类必须入 pin。** 任何达到或超过 `LARGE_CLASS_THRESHOLD`(800 行)却不在 pin 中的类都会失败。因此一个类无法悄悄长成大类;跨过 800 行就强制按其当时行数显式入 pin。
4. **无失效 pin。** 每条 pin 必须对应一个存在的生产类。删除或重命名一个类就强制剪掉它的 pin,保持 pin 诚实。
@@ -29,7 +31,7 @@
### 阈值及其理由
- **`LARGE_CLASS_THRESHOLD = 800`。** 一个略低于当前最小大类(802 行)的整数行界。它覆盖当前每个聚合与门面,并把"达到 800 行"视为一个类大到值得拥有一个显式、经评审的上限的临界点。低于它的类可自由改动,故对小 helper 的普通编辑不会触发门禁。
- **`GLOBAL_MAX_LINES = 4253`。** 今天能通过的最紧值(当前最大值)。它是一道任何类——无论是否入 pin——都不得超过的兜底,并随剩余大类(`WorkspaceCsiApplicationInventory` 3,329、`ToolPublicationDataStore` 2,709、`JdbcRuntimeBindingRepository` 2,671、`ManagedExtensionRecordStore` 2,170,以及 broker 门面)被分解而下调。
- **`GLOBAL_MAX_LINES = 4216`。** 今天能通过的最紧值(当前最大值)。它是一道任何类——无论是否入 pin——都不得超过的兜底,并随剩余大类(`WorkspaceCsiApplicationInventory` 3,329、`ToolPublicationDataStore` 2,696、`JdbcRuntimeBindingRepository` 2,671、`ManagedExtensionRecordStore` 2,170,以及 broker 门面)被分解而下调。
- **范围恰为 `persistence` + `runtime-broker`。** 这是验收标准点名的两个模块。`runtime-*` provider 模块(`runtime-http`/`host`/`local`/`external`/`kubernetes`/`ssh`/`static`)是独立顶层模块、不在范围;`contracts`、`control-plane`、`adapters`、`server` 同样不在此棘轮之列。
### 棘轮纪律
@@ -38,7 +40,7 @@
## 验证
- 门禁在基线上通过:`ClassSizeRatchetTest` —— `Tests run: 1, Failures: 0, Errors: 0`(JDK 21,隔离任务仓库)。Checkstyle(含测试源码)对新测试报 0 违规。
- 历史基线验证:`ClassSizeRatchetTest` —— `Tests run: 1, Failures: 0, Errors: 0`(JDK 21,隔离任务仓库)。Checkstyle(含测试源码)对新测试报 0 违规。
- 每条规则都经字节级备份+sha256 校验还原的变异验证:
- 把 `ManagedAgentStore` 的上限降到 100 → 失败:"grew to 1170 lines, above its ratchet ceiling 100"(规则 2)。
- 删除 `ManagedAgentStore` 的 pin 条目 → 失败:"reached 1170 lines (>= 800) but is not pinned"(规则 3)。
@@ -46,6 +48,8 @@
- 把 `GLOBAL_MAX_LINES` 降到 100 → 失败:"… above the global ceiling 100"(规则 1)。
- 还原后门禁重新转绿。
- 回执 helper 提取的验收要求 CI 中的源码体量门禁及精确回执确认测试(`RuntimeSshAcknowledgementTest`、`RuntimeBrokerServiceTest`)通过。提取仅改变方法所属类,比较函数体和确认锁定语义保持不变。
## 后续
- 每分解一个剩余大类,就下调其上限(或剪掉它)并下调 `GLOBAL_MAX_LINES`;当没有任何类超过约定棘轮时,结构评审账本的验收标准即达成。
+150
View File
@@ -0,0 +1,150 @@
# FileChanged hooks
[English](file-changed-hooks.md) | [简体中文](file-changed-hooks.zh-CN.md)
## Problem and current state
Integrators need to observe files written by Qwen Code, shell commands, editors,
or another process. Tool completion hooks cannot observe every writer. The
existing settings, extension, and skill watchers serve their own components;
there is no general file-change hook. Memory files are one use case, rather
than a new category of events.
## Proposed behavior
Add `FileChanged`, with the common hook input fields plus an absolute
`file_path` and an `event` of `add`, `change`, or `unlink`. Notifications describe
filesystem observations after a change. They cannot veto or undo a write,
identify its author, or add model context. The session ID identifies the
listening session, even when another session writes the shared directory.
For static registration, the matcher contains literal filenames in the session
working directory, separated by `|` (for example `.envrc|.env`). Omitted, empty,
`*`, and `.*` matchers register no static paths. They receive events from the session’s shared watch set, including dynamic
paths. Matching an observed event uses its basename and the existing hook
matcher rules. Static registration is not a glob or a recursive workspace scan.
Static roots accept only bare literal filenames, including dotfiles. Regex/glob
metacharacters and path separators do not create static roots. Existing
directories and paths that cannot be inspected are excluded; missing files may be watched
for creation. Static file discovery observes their parent directory while excluding
unrelated entries before traversal. Use explicit `watchPaths` for recursive directories. Changing
the session working directory relocates static roots before relocation finishes;
explicit absolute dynamic roots stay unchanged. Bootstrap configs that disabled
watching remain disabled.
Successful synchronous `SessionStart` and `FileChanged` outputs can return absolute files
or directories in `hookSpecificOutput.watchPaths`. Directories are watched
recursively, including future children. Recursive discovery skips `.git` and
`node_modules` subtrees unless a path inside them is explicitly registered.
Only regular files and directories are eligible after resolving explicitly
registered symbolic links; no depth limit is imposed.
Discovery observes the nearest existing
parent directory so deeply missing paths and deleted/recreated roots can appear
later. Only requested files and directory descendants produce notifications;
unrelated subdirectories are excluded before traversal. Discovery may enumerate
entries in an existing ancestor directory. The last valid, explicitly supplied
list in hook execution order replaces the previous dynamic list; an empty list
clears it, and an omitted or invalid list leaves it unchanged. Static paths
remain registered. Async hook output does not register paths.
Recursive discovery ignores atomic-write temporary files whose names end in
`.<12 lowercase hex characters>.tmp`, even if a delayed or failed rename leaves
them present. Explicitly registered files remain observable, and directories
with this suffix remain traversable. Other `.tmp` files are observed normally.
The initial directory scan is not replayed. Native filesystem events from a
recent write may arrive after registration. Explicit file and directory registrations
resolve symbolic links, including links in their ancestors, while notifications keep
the configured path spelling. Each registered alias receives its own notification
when aliases share a target. Registration refreshes resolve the targets again;
retargeting a link requires a refresh. Symbolic links discovered inside a recursive
root are not traversed. Watchers settle writes before reporting them,
using the existing chokidar dependency. Initial registration waits for watcher
readiness during HookSystem initialization, even without a SessionStart hook;
dynamic registration waits before SessionStart returns. Reconfiguration is best effort, rather
than a transaction or a change journal.
## Components and ownership
| Component | Change |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Hook types, planner, event handler, display metadata | Add the event, payload, and basename matching |
| HookSystem | Own the watcher, consume outputs, execute notifications with the listening runtime/session owner |
| Client session-start lifecycle | Run internal watcher initialization even when only FileChanged hooks are configured |
| FileChangedWatcher | Maintain static and dynamic paths, serialize callbacks, close and cancel on disposal |
| Config | Dispose only its owned hook system during shutdown; validate explicit bus payloads |
| CLI settings and generated schema, hook UI, ACP metadata, SDK event union | Recognize the event through existing configuration and display surfaces |
| Hook documentation | Explain registration, memory-directory example, and limitations |
The first implementation supports local, non-managed sessions. SSH execution
environments, shell sandbox workers, managed execution engines (including ACP),
and managed dispatchers do not create a
host filesystem watcher. A remote absolute path must never be interpreted as a
host path. Supporting hosted runtimes requires a separate design at the actual
filesystem owner. This feature introduces no daemon route or memory API.
ACP workspace/bootstrap and MCP reconnect initialization load the hook registry
with file watching disabled; actual session configs own their subscriptions. Session hook manager
mutations, including skill registration and removal, refresh the listening
session’s static roots without a reload.
Hook reload and enable/disable update the configured static roots. Events are
filtered through the current trusted registry. Shutdown stops intake, aborts
running hook commands, discards queued notifications, and releases the watcher.
Session rotation discards the outgoing dynamic roots and queued events, then
rebinds static roots to the new listening session before its first turn. A late
outgoing SessionEnd completion must not stop the replacement watcher. Reload
or skill registration after rotation can establish current-session ownership even
when no SessionStart hook ran. Ended sessions remain disarmed until a new
session or an explicit SessionStart; initialization opt-outs remain effective.
Derived configs do not own a second watcher. Failure to watch or execute a hook
is logged without changing the originating file operation.
## Scope and decisions
- Reuse chokidar and the existing hook runner, registry, trust checks, and timeout
handling; do not add a second memory persistence pipeline.
- Register only paths explicitly requested by hooks. There is no implicit memory
directory registration, memory enable/disable event, or content in the payload.
- Resolve explicit registrations inside FileChangedWatcher and map observations
back to their configured aliases there. This keeps basename matching and queued
events independent of a target's real path without enabling recursive link following.
- Deliver only file `add`, `change`, and `unlink` events, excluding directory-self
`addDir` and `unlinkDir`. Integrations tracking a watched root's own lifecycle
must check filesystem state (for example with `stat`) and reconcile independently;
a child's `unlink` does not prove that the root was removed.
- Hooks that write a watched file can trigger themselves. Handlers must avoid
such writes or deduplicate their work.
- Notifications are not durable: filesystem coalescing, process downtime,
inaccessible paths, and reconfiguration can leave gaps. Business integrations
own initial scans, deletion reconciliation, retry, and synchronization.
- A dedicated `MemoryChanged` API is not part of this proposal. This PR references
issue #12558 without closing its independent memory-toggle requirement.
## Validation and acceptance criteria
Package tests and a real hook-runner probe must verify static filename events,
dynamic directories outside cwd, deeply missing paths, root deletion/recreation,
future nested files, add/change/unlink,
replacement of dynamic roots while retaining static roots, invalid output,
initial-scan suppression, listener-session ownership, session rotation, failure isolation,
reload/disable, and shutdown with queued work. Remote and managed configs must
not start a local watcher. MCP reconnect must opt out, and late registration
after a rotation without SessionStart must rebind only to the current session.
Temporary siblings must stay silent through delayed rename and orphan cleanup;
the final target, ordinary `.tmp` files, explicit file registrations, and
similarly named directories must remain observable.
Explicit symbolic-link files and directory roots must report target changes with
the configured alias, including multiple aliases to one target and target changes
after a registration refresh. Implicit child links and unrelated siblings stay silent.
Existing SessionStart additional context must still
work when another hook returns watchPaths.
Run build, typecheck, bundle, focused hook/config/schema tests, and the isolated
local CLI test plan. Record macOS runtime results separately from Linux/Windows
CI and from static inspection of Claude Code's directory registration behavior.
## Open questions and follow-up
No open question blocks the local implementation. Hosted/SSH filesystem owners
and durable business synchronization are follow-up work, not implied support.
+117
View File
@@ -0,0 +1,117 @@
# FileChanged hooks
[English](file-changed-hooks.md) | [简体中文](file-changed-hooks.zh-CN.md)
## 问题与当前状态
业务集成需要观察 Qwen Code、Shell 命令、编辑器或其他进程写入的文件。
工具完成 hook 无法覆盖所有写入来源。现有设置、扩展和 skill watcher
仅服务于各自组件,没有通用文件变化 hook。Memory files 是其中一种用途,
不需要另设一类事件。
## 提议行为
新增 `FileChanged`,输入包含通用 hook 字段,以及绝对路径 `file_path` 和取值为
`add`、`change` 或 `unlink` 的 `event`。通知描述变化发生后的文件系统观察结果,
不能否决或撤销写入、识别写入者或向模型注入上下文。即使共享目录由另一个会话
写入,session ID 仍表示监听会话。
静态注册使用 matcher 指定会话工作目录中的字面文件名,以 `|` 分隔,例如
`.envrc|.env`。省略、空字符串、`*` 和 `.*` 不注册静态路径,但可接收会话共享监听集合中的事件,包括动态路径。事件筛选使用 basename 和现有 hook matcher 规则。静态注册不解释 glob,
也不递归扫描整个工作区。
静态根仅接受不含路径分隔符的字面文件名(包含点文件);正则或 glob 元字符
不产生静态根。已存在的目录和无法检查的路径不注册,尚不存在的文件仍可监听
创建事件。静态文件发现监听父目录,但在遍历前排除无关条目。递归目录请通过显式 `watchPaths` 注册。会话工作目录迁移完成前会
重新定位静态根;显式绝对动态根保持不变。初始化时禁用监听的 bootstrap
配置仍保持禁用。
执行成功的同步 `SessionStart` 和 `FileChanged` 输出可通过 `hookSpecificOutput.watchPaths`
提供绝对文件或目录路径。目录递归监听,包括未来新增的子文件。递归发现默认跳过
`.git` 和 `node_modules` 子树,但显式注册其中的路径仍会生效。解析显式注册的符号链接后,
仅处理普通文件和目录,不限制发现深度。发现过程监听最近的
已存在父目录,使多层缺失路径和删除后重建的根能够在之后出现。只有请求的文件和
目录后代产生通知,无关子目录在遍历前排除;发现过程可能枚举已有祖先目录的条目。按 hook 执行顺序,
最后一个显式提供且有效的列表替换之前的动态列表;空列表清空,省略或非法列表
保持不变。静态路径继续保留。异步 hook 输出不注册路径。
递归发现忽略文件名以 `.<12 位小写十六进制字符>.tmp` 结尾的原子写入临时文件,
即使重命名延迟或失败后文件仍存在。显式注册的文件仍可观察,同样后缀的目录仍会
遍历,其他 `.tmp` 文件正常产生通知。
初始目录扫描不回放。最近写入的 native 文件系统事件可能在注册后才到达。
显式文件和目录注册会解析符号链接(包含祖先路径中的链接),通知仍使用配置中的
路径拼写。多个已注册别名指向同一目标时,每个别名各收到一条通知。刷新注册时会
重新解析目标;链接改指其他目标后需要刷新。递归根内发现的符号链接仍不遍历。
复用已有 chokidar 依赖,在写入稳定后报告事件。静态注册在
HookSystem 初始化时等待 watcher 就绪,无需额外配置 SessionStart hook;
动态注册等待就绪再返回 SessionStart。重新配置仅提供尽力而为的观察,不是事务或
变更日志。
## 组件与归属
| 组件 | 变化 |
| ---------------------------------------------------------- | ------------------------------------------------------------- |
| Hook 类型、planner、event handler、展示元数据 | 添加事件、输入和 basename 匹配 |
| HookSystem | 拥有 watcher、消费输出,按监听 runtime/session 的归属执行通知 |
| Client 会话启动生命周期 | 仅配置 FileChanged hook 时也执行内部 watcher 初始化 |
| FileChangedWatcher | 管理静态与动态路径、串行回调、销毁时关闭并取消 |
| Config | 关闭时仅销毁自己拥有的 hook system,校验显式 bus 输入 |
| CLI 设置与生成 schema、hook UI、ACP 元数据、SDK 事件 union | 在已有配置和展示入口识别事件 |
| Hook 文档 | 说明注册、memory 目录示例和限制 |
首版支持本地非托管会话。SSH execution environment、Shell sandbox worker 和
managed execution engine(包含 ACP)及 managed dispatcher 不创建宿主文件 watcher。远程绝对路径不能被解释为宿主路径。
托管 runtime 的支持需要在实际文件系统拥有者处另行设计。本功能不新增 daemon
route 或 memory API。
ACP workspace/bootstrap 和 MCP reconnect 初始化加载 hook registry,但禁用文件监听;
实际会话 Config 拥有各自的订阅。Session hook manager 的变更(含 skill 注册及移除)无需 reload
即可刷新监听会话的静态根路径。
Hook reload 和启用/禁用会更新静态根路径;事件通过当前可信 registry 筛选。
关闭时停止接收、取消运行中的 hook 命令、丢弃排队通知并释放 watcher。
会话轮换时丢弃旧会话的动态根路径和排队事件,并在新会话的首轮之前将静态根
重新绑定到新监听会话。旧 SessionEnd 延迟完成不能停止替换后的 watcher。即使没有
运行 SessionStart hook,轮换后的 reload 或 skill 注册也可建立当前会话的监听归属。
已结束会话保持停止监听,直到新会话或显式 SessionStart;初始化时的禁用选项仍有效。
派生 Config 不拥有第二个 watcher。监听或 hook 执行失败记录日志,
不改变原始文件操作。
## 范围与决策
- 复用 chokidar 以及已有 hook runner、registry、信任校验和超时处理,
不新增 memory 持久化链路。
- 仅注册 hook 显式要求的路径。不隐式注册 memory 目录、不提供 memory
启用/禁用事件,输入不包含文件内容。
- 在 FileChangedWatcher 内解析显式注册路径,并将观察结果映射回配置的别名。
这样 basename 匹配和排队事件不依赖目标的真实路径,也无需启用递归跟随链接。
- 仅投递文件的 `add`、`change`、`unlink` 事件,不投递目录自身的 `addDir` 和
`unlinkDir`。如需观察监听根目录自身的生命周期,集成方应自行检查文件系统状态
(例如 `stat`)并对账;子文件的 `unlink` 不能证明根目录已删除。
- Hook 写入被监听文件可能触发自身;业务处理器需要避免这种写入或自行去重。
- 通知不持久化:文件系统合并、进程离线、路径不可访问和重新配置都可能产生
缺口。业务集成负责初始扫描、删除对账、重试和同步。
- 本提案不包含专用 `MemoryChanged` API。本 PR 仅引用 #12558,不关闭其中
独立的 memory 开关需求。
## 验证与验收标准
包内测试和真实 hook runner 实验需要覆盖静态文件事件、cwd 外动态目录、未来
新增的嵌套文件、多层缺失路径、根删除后重建、add/change/unlink、替换动态根且保留静态根、非法输出、初始
扫描抑制、监听会话归属、会话轮换、失败隔离、reload/disable 和存在排队任务时的关闭。
远程与托管 Config 不得启动本地 watcher;MCP reconnect 必须禁用监听,未运行
SessionStart 的轮换后延迟注册只可绑定到当前会话。另一个 hook 返回 watchPaths 时,
已有 SessionStart additional context 行为仍须正常。临时同级文件在延迟重命名及
孤儿清理过程中必须保持静默;最终目标、普通 `.tmp` 文件、显式注册文件及同样
命名的目录必须仍可观察。
显式符号链接文件和目录根必须以配置的别名报告目标变更,包含同一目标的多个别名,
以及刷新注册后目标改变的情况。隐式子链接和无关兄弟路径必须保持静默。
执行 build、typecheck、bundle、针对 hook/config/schema 的测试,以及隔离本地
CLI 测试计划。macOS runtime 结果需与 Linux/Windows CI、Claude Code 目录
注册行为的静态检查证据分别记录。
## 待定问题与后续工作
没有阻塞本地实现的待定问题。托管/SSH 文件系统归属以及持久业务同步属于后续
工作,不代表本实现已经支持。
+196
View File
@@ -0,0 +1,196 @@
# FileChanged hooks
[English](file-changed-hooks.md) | [简体中文](file-changed-hooks.zh-CN.md)
Use `FileChanged` to observe files changed by Qwen Code, shell commands, editors,
or other processes. It runs in local, non-managed sessions. See the
[Hooks guide](hooks.md) for hook configuration, executors, and trust rules.
## Event input
The input includes the [common hook fields](hooks.md#hook-input-structure) and:
```json
{
"file_path": "/home/me/work/demo/.envrc",
"event": "change"
}
```
`file_path` is absolute. `event` is `add`, `change`, or `unlink` for an observed
file addition, modification, or deletion. The common `session_id` and `cwd`
belong to the listening session; they do not identify the writer. Multiple
sessions watching a shared directory can each receive its changes. File content
is not included.
Only file events are delivered. Directory-self `addDir` and `unlinkDir` events
are not delivered. Integrations tracking a watched root's own lifecycle must
check filesystem state (for example with `stat`) and reconcile independently;
a child's `unlink` does not prove that the root was removed.
## Watch filenames in the working directory
Add a `FileChanged` hook with literal filenames separated by `|`:
```json
{
"hooks": {
"FileChanged": [
{
"matcher": ".envrc|.env",
"hooks": [
{
"type": "command",
"name": "configuration-changed",
"command": "node \"$QWEN_PROJECT_DIR/.qwen/hooks/on-file-changed.mjs\""
}
]
}
]
}
}
```
This registers `.envrc` and `.env` in the session's cwd. Static registration does
not expand globs or scan directories recursively. Omitted or empty matchers,
`*`, and `.*` register no static paths; those hooks receive events from the
session's watched paths, including dynamic paths. Events are filtered against
the file's basename using the
[existing matcher rules](hooks.md#matcher-patterns).
Regex patterns filter already watched paths; register their files or directories
explicitly through `watchPaths`.
Static roots accept only bare literal filenames, including dotfiles. Regex/glob
metacharacters and path separators do not create static roots. Existing
directories and paths that cannot be inspected are excluded; missing files may be watched
for creation. Static file discovery observes their parent directory while excluding
unrelated entries before traversal. Use explicit `watchPaths` for recursive directories. Changing
the session working directory relocates static roots before relocation finishes;
explicit absolute dynamic roots stay unchanged.
## Watch directories and paths outside cwd
A successful synchronous `SessionStart` hook can register absolute files or directories
through `hookSpecificOutput.watchPaths`. Directories are watched recursively,
including children created later. Recursive discovery skips `.git` and
`node_modules` subtrees. To observe a path inside them, register that file or
directory explicitly. Special file types are ignored, and ordinary directories
have no depth limit. Initial registration waits for watcher
readiness before `SessionStart` returns.
Recursive discovery ignores atomic-write temporary files named
`<target>.<12 lowercase hex characters>.tmp`, including delayed renames and
orphans. Explicitly registering such a file still observes it. Directories with
this suffix remain traversable, and other `.tmp` files are observed normally.
For example, register memory folders:
```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"name": "watch-memory-folders",
"command": "node \"$QWEN_PROJECT_DIR/.qwen/hooks/watch-memory.mjs\""
}
]
}
],
"FileChanged": [
{
"hooks": [
{
"type": "command",
"name": "memory-file-changed",
"command": "node \"$QWEN_PROJECT_DIR/.qwen/hooks/on-file-changed.mjs\""
}
]
}
]
}
}
```
Have `watch-memory.mjs` print this JSON to stdout. Replace the example paths with
the actual absolute folders shown by `/memory`:
```json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"watchPaths": [
"/home/me/.qwen/projects/demo/memory",
"/home/me/.qwen/memories"
]
}
}
```
The `FileChanged` hook has no matcher, so it receives changes throughout those
registered directories. Its handler reads the event JSON from stdin. Choose a
log or synchronization destination outside the watched paths to avoid triggering
the handler with its own writes.
## Update dynamic paths
For several workspaces in the same filesystem, register all their actual memory
directories in one list. Route notifications using `file_path` and your own
directory-to-workspace mapping, rather than the listening session's `cwd`.
Independent sessions can observe the same shared root, so deduplicate the work
in the business handler. Roots in another container or remote instance need a
watcher in that filesystem owner; registering their paths on the host does not
observe them.
Successful synchronous `SessionStart` and `FileChanged` output can set `watchPaths`. Include
the matching `hookEventName` in `hookSpecificOutput`.
| Output | Effect on dynamic paths |
| --------------------------------------- | ---------------------------------- |
| A list of absolute files or directories | Replaces the previous dynamic list |
| `[]` | Clears the dynamic list |
| Omitted or invalid `watchPaths` | Keeps the previous dynamic list |
| Output from an async hook | Does not register or change paths |
When several hooks return lists, the last valid, explicitly supplied list in
hook execution order wins. All entries must be absolute paths; one invalid entry
makes the list invalid. Static filename registrations remain active after a
dynamic replacement or clear.
To clear dynamic paths from a `FileChanged` handler, return:
```json
{
"hookSpecificOutput": {
"hookEventName": "FileChanged",
"watchPaths": []
}
}
```
## Observation limits
The initial directory scan is not replayed. Native filesystem events from a
recent write may arrive after registration. Explicitly registered symbolic-link
files and directory roots observe their targets, including links in ancestor paths.
Notifications use the configured alias; registering multiple aliases to one target
produces a notification for each alias. Refresh registration after retargeting a
link. Symbolic links discovered inside a recursive root are not traversed.
Events are reported after writes settle.
Watching is best effort: filesystem coalescing, inaccessible paths, process
downtime, and path reconfiguration can leave gaps. Integrations that require a
complete synchronized copy must handle initial scans, reconciliation, and retry.
Watcher or hook failures are logged and leave the originating file operation
unchanged.
`FileChanged` cannot veto or undo a write or add model context. Only explicitly
registered paths are watched: memory folders are not registered automatically,
and there is no dedicated auto-memory enable/disable event. A hook that writes
a watched file can trigger itself; avoid those writes or deduplicate the work.
SSH execution environments, shell sandbox workers, and managed dispatchers do
not start a local filesystem watcher. Normal hook enablement and trust rules
still apply. Watching stops when its owning session shuts down.
@@ -0,0 +1,178 @@
# FileChanged hooks
[English](file-changed-hooks.md) | [简体中文](file-changed-hooks.zh-CN.md)
使用 `FileChanged` 观察 Qwen Code、Shell 命令、编辑器或其他进程修改的文件。
该功能适用于本地非托管会话。Hook 配置、执行器及信任规则见
[Hooks 指南](hooks.md)。
## 事件输入
输入包含[通用 hook 字段](hooks.md#hook-input-structure)以及:
```json
{
"file_path": "/home/me/work/demo/.envrc",
"event": "change"
}
```
`file_path` 为绝对路径。`event` 取值为 `add`、`change` 或 `unlink`,分别表示
观察到文件新增、修改或删除。通用字段 `session_id` 和 `cwd` 属于监听会话,
不表示写入者。多个会话监听同一共享目录时,各会话都可能收到变化通知。
输入不包含文件内容。
仅投递文件事件,不投递目录自身的 `addDir` 和 `unlinkDir`。如需观察监听根目录
自身的生命周期,集成方应自行检查文件系统状态(例如 `stat`)并对账;
子文件的 `unlink` 不能证明根目录已删除。
## 监听工作目录中的文件名
在 `FileChanged` hook 中以 `|` 分隔字面文件名:
```json
{
"hooks": {
"FileChanged": [
{
"matcher": ".envrc|.env",
"hooks": [
{
"type": "command",
"name": "configuration-changed",
"command": "node \"$QWEN_PROJECT_DIR/.qwen/hooks/on-file-changed.mjs\""
}
]
}
]
}
}
```
该配置注册会话 cwd 下的 `.envrc` 和 `.env`。静态注册不展开 glob,
也不递归扫描目录。省略 matcher、空字符串、`*` 和 `.*` 均不注册静态路径;
这些 hook 接收会话已监听路径的事件,包括动态路径。事件筛选使用文件 basename 和
[现有 matcher 规则](hooks.md#matcher-patterns)。
正则表达式只筛选已监听路径的事件;请通过 `watchPaths` 显式注册对应文件或目录。
静态根仅接受不含路径分隔符的字面文件名(包含点文件);正则或 glob 元字符
不产生静态根。已存在的目录和无法检查的路径不注册,尚不存在的文件仍可监听
创建事件。静态文件发现监听父目录,但在遍历前排除无关条目。递归目录请通过显式 `watchPaths` 注册。会话工作目录迁移完成前会
重新定位静态根;显式绝对动态根保持不变。
## 监听目录及 cwd 外的路径
执行成功的同步 `SessionStart` hook 可通过 `hookSpecificOutput.watchPaths` 注册绝对文件
或目录路径。目录递归监听,包括之后新增的子文件。递归发现默认跳过 `.git` 和
`node_modules` 子树;如需观察其中的路径,请显式注册对应文件或目录。特殊文件类型
会被忽略,普通目录不限制发现深度。首次注册等待 watcher
就绪后才返回 `SessionStart`。
递归发现忽略名为 `<目标>.<12 位小写十六进制字符>.tmp` 的原子写入临时文件,
包括延迟重命名和孤儿文件。显式注册此类文件仍可观察。同样后缀的目录仍会遍历,
其他 `.tmp` 文件正常产生通知。
例如,注册 memory 目录:
```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"name": "watch-memory-folders",
"command": "node \"$QWEN_PROJECT_DIR/.qwen/hooks/watch-memory.mjs\""
}
]
}
],
"FileChanged": [
{
"hooks": [
{
"type": "command",
"name": "memory-file-changed",
"command": "node \"$QWEN_PROJECT_DIR/.qwen/hooks/on-file-changed.mjs\""
}
]
}
]
}
}
```
让 `watch-memory.mjs` 向 stdout 输出以下 JSON。
将示例路径替换为 `/memory` 中显示的实际绝对目录路径:
```json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"watchPaths": [
"/home/me/.qwen/projects/demo/memory",
"/home/me/.qwen/memories"
]
}
}
```
`FileChanged` hook 未指定 matcher,因此接收已注册目录中各文件的变化。
处理器从 stdin 读取事件 JSON。将日志或同步结果写到监听路径之外,
避免处理器自己的写入再次触发自身。
## 更新动态路径
同一文件系统中的多个 workspace,可以在一个列表中注册各自实际 memory 目录。
业务根据 `file_path` 和自有的目录到 workspace 映射分发通知,不使用监听会话的
`cwd` 判断归属。独立会话可能观察同一个共享根路径,因此业务处理器需要去重。
位于其他容器或远程实例的根路径需要在对应文件系统拥有者处监听;在宿主注册
其路径不能观察到变化。
执行成功的同步 `SessionStart` 和 `FileChanged` 输出都可以设置 `watchPaths`。
在 `hookSpecificOutput` 中填写对应的 `hookEventName`。
| 输出 | 对动态路径的影响 |
| ----------------------- | ------------------ |
| 绝对文件或目录路径列表 | 替换之前的动态列表 |
| `[]` | 清空动态列表 |
| 省略或非法 `watchPaths` | 保留之前的动态列表 |
| 异步 hook 的输出 | 不注册或修改路径 |
多个 hook 返回列表时,按 hook 执行顺序,最后一个显式提供且有效的列表生效。
所有条目均须为绝对路径;任一条目非法会使整个列表无效。
替换或清空动态路径后,静态文件名注册仍继续生效。
从 `FileChanged` 处理器清空动态路径时,返回:
```json
{
"hookSpecificOutput": {
"hookEventName": "FileChanged",
"watchPaths": []
}
}
```
## 观察范围与限制
初始目录扫描不回放。最近写入的原生文件系统事件可能在注册后才到达。
显式注册的符号链接文件和目录根会观察其目标,也支持祖先路径中的符号链接。
通知使用配置的别名;同一目标注册多个别名时,每个别名各收到一条通知。
链接改指其他目标后应刷新注册。递归根内发现的符号链接仍不遍历。
写入稳定后才报告事件。
监听尽力而为:文件系统合并事件、
路径不可访问、进程离线和路径重新配置都可能产生缺口。
需要完整同步副本的集成应自行负责初始扫描、对账及重试。
Watcher 或 hook 失败会记录日志,原始文件操作保持不变。
`FileChanged` 不能否决或撤销写入,也不能向模型添加上下文。
仅监听显式注册的路径:不会自动注册 memory 目录,
不提供专用 auto-memory 开关事件。Hook 写入被监听文件可能触发自身;
应避免这些写入或对处理工作去重。
SSH execution environment、Shell sandbox worker 和 managed dispatcher
不启动本地文件系统 watcher。常规 hook 启用和信任规则仍然适用。
监听会话关闭时停止监听。
+28
View File
@@ -394,6 +394,7 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo
| `TodoCreated` | When a new todo item is created | None (always fires) |
| `TodoCompleted` | When a todo item is marked as completed | None (always fires) |
| `InstructionsLoaded` | When a context file such as `QWEN.md`, or a file it imports, is loaded | File path of the loaded file |
| `FileChanged` | After an explicitly watched file is added, modified, or deleted | Basename of the changed file |
### Matcher Patterns
@@ -416,6 +417,9 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo
| Stop Events | `StopFailure` | ✅ Regex | Error type: `rate_limit`, `authentication_failed`, `billing_error`, `invalid_request`, `server_error`, `max_output_tokens`, `loop_detected`, `unknown` |
| Message Display | `MessageDisplay` | ❌ No | N/A |
| Instruction Events | `InstructionsLoaded` | ✅ Regex | File path of the loaded file |
| File Events | `FileChanged` | ✅ Regex | Basename of the changed file |
For `FileChanged`, registering static paths uses literal filenames in cwd; filtering events uses the basename and the rules below. See [FileChanged hooks](file-changed-hooks.md) for dynamic files and recursive directories.
**Matcher Syntax:**
@@ -902,6 +906,7 @@ A command hook that exits 0 with plain-text `stdout` has that text treated as `a
**Output Options**:
- `hookSpecificOutput.additionalContext`: context to be available in the session
- `hookSpecificOutput.watchPaths`: absolute files or recursive directories to watch for `FileChanged`, from synchronous output with `hookEventName: "SessionStart"`. See [FileChanged path registration](file-changed-hooks.md#watch-directories-and-paths-outside-cwd) for replacement, clearing, and validation rules.
- Standard hook output fields
**Example Output**:
@@ -1526,6 +1531,29 @@ It fires when the session starts, when context files are reloaded during the ses
- **None** - InstructionsLoaded output and exit codes are ignored.
#### FileChanged
**Purpose**: Observes additions, modifications, and deletions of explicitly watched files in local, non-managed sessions. Changes may come from Qwen Code, shell commands, editors, or other processes. The common input fields identify the listening session, including when another session writes a shared directory.
**Matcher**: Filters against the file basename using the existing matcher rules. Literal cwd filenames separated by `|` also register static paths. Omitted or empty matchers, `*`, and `.*` register no static paths; they receive events from the session's watched paths, including dynamically registered paths.
**Event-specific fields**:
```json
{
"file_path": "/home/me/work/demo/.envrc",
"event": "change"
}
```
`file_path` is absolute. `event` is `add`, `change`, or `unlink`. The initial directory scan is not replayed, but native filesystem events from a recent write may arrive after registration. Symbolic links are not traversed; register the real directory root. Notifications are best effort after writes settle. Hook output cannot veto or undo a write or add model context.
**Output Options**:
- Successful synchronous `hookSpecificOutput.watchPaths`, with `hookEventName: "FileChanged"`, replaces the dynamic watch list with absolute files or recursive directories. The last valid, explicit list in hook execution order wins; `[]` clears it, while omitted or invalid lists leave it unchanged. Async output does not register paths. Static registrations remain active.
See [FileChanged hooks](file-changed-hooks.md) for configuration, a memory-directory example, session ownership, and self-trigger and delivery limits. Memory directories are watched only when explicitly registered; there is no dedicated auto-memory enable/disable event.
## Hook Configuration
Hooks are configured in Qwen Code settings, typically in `.qwen/settings.json` or user configuration files. Hooks in the system settings files (System and SystemDefaults) load with the source System and, like user hooks, regardless of folder trust. Within one event, sequential hooks from settings and extensions run in this order: Project, User, System, Extension.
@@ -513,8 +513,8 @@ it(`H02b typed file survives mutation and cold resume (${runtime}+${renderer})`,
writeFileSync(file, changed);
expect(readFileSync(file, 'utf8')).toBe(changed);
await api.start(original.sessionId);
await api.visible('RECOVERY_file_0_DONE', 'cold-assistant-ready');
const cold = await api.visible(submitted, 'cold-typed-form');
const cold = await api.visible('RECOVERY_file_0_DONE', 'cold-typed-form');
expect(cold).toContain(submitted);
expect(cold).toContain('RECOVERY_file_0_DONE');
expect(cold).not.toContain(changed.trim());
expect(api.server.requests).toHaveLength(1);
+4
View File
@@ -1688,6 +1688,10 @@ export const IDLE_HOOK_EVENTS: Record<HookEventName, ServeHookEventMeta> = {
description: 'When an instruction or context file is loaded',
matcherKind: 'filePath',
},
FileChanged: {
description: 'When a watched file is added, changed, or deleted',
matcherKind: 'filePath',
},
};
// ---------------------------------------------------------------------------
@@ -26460,6 +26460,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect(mockConfig.initialize).toHaveBeenCalledWith({
skipLlmInitialization: true,
skipFileWatching: true,
// F2 (#4175 commit 6 review fix — claude-opus-4-7 W119): also
// pins that the bootstrap path opts out of MCP discovery (so
// bootstrap + per-session don't double-spawn N stdio servers).
@@ -26591,6 +26592,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect(mockConfig.initialize).toHaveBeenCalledWith({
skipLlmInitialization: true,
skipFileWatching: true,
// F2 (#4175 commit 6 review fix — claude-opus-4-7 W119): also
// pins that the bootstrap path opts out of MCP discovery (so
// bootstrap + per-session don't double-spawn N stdio servers).
@@ -3170,6 +3170,7 @@ export async function runAcpAgent(
try {
await config.initialize({
skipLlmInitialization: true,
skipFileWatching: true,
// Bootstrap skips MCP discovery — each session runs its own
// pool-routed discovery, so bootstrap-level spawns would be
// redundant subprocess leaks (W119).
+27 -18
View File
@@ -382,27 +382,36 @@ describe('mcp reconnect command', () => {
});
describe('process scope (issue #9944)', () => {
it('initializes the throwaway config without background MCP discovery', async () => {
// The command runs its own targeted `discoverToolsForServer`; the
// background incremental pass would race `config.shutdown()` and
// re-arm health-check timers that keep the process alive forever.
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'test-server': { command: '/path/to/server' },
it.each([false, true])(
'initializes without background discovery, chat or file hooks (all=%s)',
async (all) => {
// The command runs its own targeted `discoverToolsForServer`; the
// background incremental pass would race `config.shutdown()` and
// re-arm health-check timers that keep the process alive forever.
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'test-server': { command: '/path/to/server' },
},
},
},
});
});
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': 'test-server', all: false });
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': all ? undefined : 'test-server', all });
expect(mockConfig.initialize).toHaveBeenCalledWith({
skipMcpDiscovery: true,
});
});
expect(mockConfig.initialize).toHaveBeenCalledWith({
skipMcpDiscovery: true,
skipFileWatching: true,
skipLlmInitialization: true,
});
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
'test-server',
);
expect(mockConfig.shutdown).toHaveBeenCalledOnce();
},
);
it('clarifies that single-server success only covers this process', async () => {
mockedLoadSettings.mockReturnValue({
+5 -1
View File
@@ -139,7 +139,11 @@ async function createMinimalConfig(): Promise<Config> {
// pass `initialize()` would otherwise start. Skipping it removes the race
// where that background pass re-arms health-check timers after
// `config.shutdown()` and leaves the process hanging (issue #9944).
await config.initialize({ skipMcpDiscovery: true });
await config.initialize({
skipMcpDiscovery: true,
skipFileWatching: true,
skipLlmInitialization: true,
});
return config;
}
+12
View File
@@ -4380,6 +4380,18 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
FileChanged: {
type: 'array',
label: 'File Changed Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a watched file is added, changed, or deleted in a local session.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
},
},
@@ -48,6 +48,7 @@ vi.mock(
);
}
if (
kind !== 'shell' &&
!(
actual.MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS as readonly string[]
).includes(kind)
@@ -676,6 +677,24 @@ describe('reconcileNeverStartedChildRuns', () => {
});
});
it('refuses a shell admit when the fixture explicitly disables its kind', async () => {
await open();
await createManagedHarnessHandle(session!).ensureRunnable();
enablement.childRun = false;
await expect(
orchestratorOf(session!).admit({
shellId: 'shell-1',
ownerScopeId: 'scope-main',
executionCallId: 'shell-1',
args: { command: 'printf hi', is_background: true },
}),
).rejects.toThrow('registered but not enabled');
expect(
session!.authority.extensionRecord('child_run', 'shell-1'),
).toBeUndefined();
});
it('never scans a child_agent record, the one kind production can commit today', async () => {
await open();
await createManagedHarnessHandle(session!).ensureRunnable();
@@ -13,7 +13,8 @@ import * as path from 'node:path';
import type { AddressInfo } from 'node:net';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resetManagedRuntimeDispatchGatesForTest } from '@qwen-code/qwen-code-core/managed-runtime/managed-runtime-dispatch-gate.js';
import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
import { LocalToolResultSegmentStore } from '@qwen-code/qwen-code-core/managed-runtime/local-managed-tool-result-store.js';
import { LocalShellResultSession } from '@qwen-code/qwen-code-core/managed-runtime/local-shell-result-session.js';
@@ -79,6 +80,7 @@ let lease: SessionWriterLease | undefined;
let session: ManagedSession | undefined;
let executor: ManagedToolExecutor | undefined;
let root: string | undefined;
beforeEach(resetManagedRuntimeDispatchGatesForTest);
afterEach(async () => {
await executor?.close();
if (server)
@@ -103,6 +105,7 @@ afterEach(async () => {
describe('Tool v3 local worker routes', () => {
it('executes one Shell and binds status and ACK to the original receipt', async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-tool-v3-'));
const executionCallId = path.basename(root);
const runtimeBaseDir = path.join(root, 'runtime');
const transcriptPath = path.join(
runtimeBaseDir,
@@ -223,7 +226,7 @@ describe('Tool v3 local worker routes', () => {
epoch: session!.activation.epoch,
},
payload: {
executionCallId: identity.executionCallId,
executionCallId,
batchId: 'batch-a',
ordinal: 0,
toolDefinitionRef: definitionRef,
@@ -236,7 +239,7 @@ describe('Tool v3 local worker routes', () => {
await harness.commitAwaitRuntime({
functionCallId: identity.callId,
toolName: 'run_shell_command',
executionCallId: identity.executionCallId,
executionCallId,
invocationBindingId: 'binding-a',
capabilityVersion: 'cap-a',
policyVersion: 'policy-a',
@@ -330,7 +333,7 @@ describe('Tool v3 local worker routes', () => {
tenantId: sessionKey.tenantId,
sessionId: sessionKey.sessionId,
turnId: 'turn-a',
executionCallId: identity.executionCallId,
executionCallId,
bindingGeneration: '1',
capturePolicy: 'complete_required',
},
@@ -345,7 +348,7 @@ describe('Tool v3 local worker routes', () => {
tenantId: sessionKey.tenantId,
sessionId: sessionKey.sessionId,
turnId: 'turn-a',
executionCallId: identity.executionCallId,
executionCallId,
bindingGeneration: '1',
capturePolicy: 'complete_required',
},
@@ -392,7 +395,7 @@ describe('Tool v3 local worker routes', () => {
});
}
const receipt = {
executionCallId: identity.executionCallId,
executionCallId,
manifest: settled.result.capture.manifest,
deliveryStatus: 'committed',
historyRevision: receiptEvent!.sequence,
@@ -421,7 +424,7 @@ describe('Tool v3 local worker routes', () => {
tenantId: sessionKey.tenantId,
sessionId: sessionKey.sessionId,
turnId: 'turn-a',
executionCallId: identity.executionCallId,
executionCallId,
bindingGeneration: '1',
capturePolicy: 'complete_required',
},
@@ -52,6 +52,9 @@ describe('executeWorktreeCleanup — has-work gate (#12758)', () => {
git(repoRoot, 'config', 'user.email', '[email protected]');
git(repoRoot, 'config', 'user.name', 't');
git(repoRoot, 'config', 'commit.gpgsign', 'false');
const emptyExcludes = path.join(repoParent, 'empty-excludes');
fs.writeFileSync(emptyExcludes, '');
git(repoRoot, 'config', 'core.excludesFile', emptyExcludes);
fs.writeFileSync(
path.join(repoRoot, '.gitignore'),
'secret.env\nnode_modules/\n',
@@ -66,6 +66,13 @@ export function getHookExitCodes(eventName: string): HookExitCode[] {
{ code: 0, description: t('stdout/stderr not shown') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.FileChanged]: [
{ code: 0, description: t('use watchPaths if provided') },
{
code: 'Other',
description: t('file change is already complete; cannot block it'),
},
],
[HookEventName.UserPromptSubmit]: [
{ code: 0, description: t('stdout shown to Qwen') },
{
@@ -183,6 +190,7 @@ export function getHookShortDescription(eventName: string): string {
[HookEventName.PostToolBatch]: t('After all tool calls in a batch resolve'),
[HookEventName.Notification]: t('When notifications are sent'),
[HookEventName.InstructionsLoaded]: t('When instruction files are loaded'),
[HookEventName.FileChanged]: t('When a watched file changes'),
[HookEventName.UserPromptSubmit]: t('When the user submits a prompt'),
[HookEventName.UserPromptExpansion]: t(
'When a slash command expands into a prompt',
@@ -243,6 +251,9 @@ export function getHookDescription(eventName: string): string {
[HookEventName.InstructionsLoaded]: t(
'Input to command is JSON with file_path, memory_type, load_reason, and optional trigger_file_path and parent_file_path.',
),
[HookEventName.FileChanged]: t(
'Input to command is JSON with absolute file_path and event (add/change/unlink). Matchers filter the basename. Synchronous hookSpecificOutput.watchPaths replaces dynamic watch paths; file changes cannot be blocked.',
),
[HookEventName.UserPromptSubmit]: t(
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).',
),
+67 -1
View File
@@ -317,6 +317,10 @@ vi.mock('../hooks/index.js', () => {
const HookSystemMock = vi.fn();
HookSystemMock.prototype.runtimeId = 'test-hook-runtime';
HookSystemMock.prototype.initialize = vi.fn().mockResolvedValue(undefined);
HookSystemMock.prototype.close = vi.fn().mockResolvedValue(undefined);
HookSystemMock.prototype.refreshFileWatchingForWorkingDirectory = vi
.fn()
.mockResolvedValue(undefined);
HookSystemMock.prototype.hasHooksForEvent = vi.fn().mockReturnValue(false);
HookSystemMock.prototype.isManaged = vi.fn().mockReturnValue(false);
HookSystemMock.prototype.getAllHooks = vi.fn().mockReturnValue([]);
@@ -6646,6 +6650,19 @@ describe('Server Config (config.ts)', () => {
]);
});
it('loads bootstrap hooks with file watching disabled', async () => {
const config = makeConfig();
await config.initialize({
skipFileWatching: true,
skipLlmInitialization: true,
skipMcpDiscovery: true,
});
expect(HookSystem).toHaveBeenCalled();
expect(config.getHookSystem()?.initialize).toHaveBeenCalledWith({
skipFileWatching: true,
});
});
it('should skip hook, skill, and file checkpointing side effects when requested', async () => {
const config = makeConfig({ fileCheckpointingEnabled: true });
@@ -9513,6 +9530,10 @@ describe('Server Config (config.ts)', () => {
it('relocateWorkingDirectory should update the session working roots', async () => {
const config = makeConfig();
const refreshFileWatchingForWorkingDirectory = vi
.fn()
.mockResolvedValue(undefined);
internals(config).hookSystem = { refreshFileWatchingForWorkingDirectory };
Object.assign(config, {
memoryRecallMode: 'structured',
memoryRecallModeInitialized: true,
@@ -9536,6 +9557,7 @@ describe('Server Config (config.ts)', () => {
await config.relocateWorkingDirectory(NEW_DIR);
expect(refreshFileWatchingForWorkingDirectory).toHaveBeenCalledOnce();
expect(cwd.chdirSpy).toHaveBeenCalledWith(NEW_DIR);
expect(config.getTargetDir()).toBe(NEW_DIR);
expect(config.getProjectRoot()).toBe(NEW_DIR);
@@ -13124,7 +13146,13 @@ describe('Model Switching and Config Updates', () => {
const config = await withHookSystem(hookSystem);
const warn = vi.spyOn(config.getDebugLogger(), 'warn');
const response = await hookRequest(config, eventName, {});
const response = await hookRequest(
config,
eventName,
eventName === 'FileChanged'
? { file_path: '/tmp/notes.md', event: 'change' }
: {},
);
expect(warn).not.toHaveBeenCalledWith(
expect.stringContaining('Unknown hook event'),
@@ -13136,6 +13164,44 @@ describe('Model Switching and Config Updates', () => {
});
describe('direct-call hook events through the hook execution bridge', () => {
it.each(['add', 'change', 'unlink'] as const)(
'forwards a valid FileChanged %s input',
async (event) => {
const fireFileChangedEvent = vi.fn().mockResolvedValue({
success: true,
allOutputs: [],
errors: [],
totalDuration: 0,
});
const config = await withHookSystem({ fireFileChangedEvent });
const response = await hookRequest(config, 'FileChanged', {
file_path: path.resolve('/memory/topic.md'),
event,
});
expect(response.success).toBe(true);
expect(fireFileChangedEvent).toHaveBeenCalledWith(
path.resolve('/memory/topic.md'),
event,
undefined,
);
},
);
it.each([
{},
{ file_path: 'relative.md', event: 'change' },
{ file_path: '/memory/topic.md', event: 'rename' },
{ file_path: 1, event: 'add' },
])(
'rejects invalid FileChanged input %j before dispatch',
async (input) => {
const fireFileChangedEvent = vi.fn();
const config = await withHookSystem({ fireFileChangedEvent });
const response = await hookRequest(config, 'FileChanged', input);
expect(response.success).toBe(false);
expect(fireFileChangedEvent).not.toHaveBeenCalled();
},
);
const dispatch = async (
method: string,
fire: ReturnType<typeof vi.fn>,
+31 -1
View File
@@ -2342,6 +2342,8 @@ export interface ConfigInitializeOptions {
* need config services (hooks, tools, MCP) before a real session exists.
*/
skipLlmInitialization?: boolean;
/** Load hooks without starting a filesystem subscription for bootstrap configs. */
skipFileWatching?: boolean;
/** @deprecated Use `skipLlmInitialization`; retained until a future major release. */
skipGeminiInitialization?: boolean;
/**
@@ -4383,7 +4385,9 @@ export class Config {
(!options?.skipHooks && !this.getDisableAllHooks()))
) {
this.hookSystem = new HookSystem(this, options?.managedHookDispatcher);
await this.hookSystem.initialize();
await this.hookSystem.initialize({
skipFileWatching: options?.skipFileWatching,
});
this.debugLogger.debug('Hook system initialized');
// Initialize MessageBus for hook execution
@@ -4446,6 +4450,28 @@ export class Config {
signal,
);
break;
case 'FileChanged': {
const filePath = input['file_path'];
const event = input['event'];
if (
typeof filePath !== 'string' ||
!path.isAbsolute(filePath) ||
(event !== 'add' &&
event !== 'change' &&
event !== 'unlink')
) {
throw new Error('Invalid FileChanged hook input');
}
const changed = await hookSystem.fireFileChangedEvent(
filePath,
event,
signal,
);
result = changed.finalOutput
? createHookOutput('FileChanged', changed.finalOutput)
: undefined;
break;
}
case 'Stop': {
// Extract context usage data from input with runtime validation
const contextUsageData = buildContextUsage(
@@ -8220,6 +8246,7 @@ export class Config {
this.backgroundTaskRegistry.disposeResidentAgents();
this.targetDir = expected;
this.cwd = expected;
await this.hookSystem?.refreshFileWatchingForWorkingDirectory();
resetPreloadedContentGenerator(this.contentGenerator);
await this.refreshCurrentRuntimeStatus(expected);
this.workspaceContext.applyRootDirectories(workspaceDirectories);
@@ -8440,6 +8467,9 @@ export class Config {
private async shutdownResourcesOnce(): Promise<void> {
let resourceError: unknown;
try {
if (Object.hasOwn(this, 'hookSystem')) {
await this.hookSystem?.close();
}
this.memoryManager.cancelMigrations();
this.clearSessionRestoreProjection();
// Drop this session's project-dir registry entry. It is registered during
+14
View File
@@ -1674,6 +1674,20 @@ describe('Gemini Client (client.ts)', () => {
expect(hookSystem.fireSessionStartEvent).not.toHaveBeenCalled();
});
it('runs session-start lifecycle for FileChanged without a SessionStart hook', async () => {
const hookSystem = {
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
};
stubHookSystem(hookSystem);
vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
(event) => event === 'FileChanged',
);
await expect(
fireHook(SessionStartSource.Resume),
).resolves.toBeUndefined();
expectSessionStart(hookSystem, SessionStartSource.Resume);
});
it('logs and returns undefined when the SessionStart hook throws', async () => {
const debugLogger = stubDebugLogger();
stubHookSystem({
+2 -1
View File
@@ -2876,7 +2876,8 @@ export class LlmClient {
if (
this.config.getDisableAllHooks() ||
!hookSystem ||
!this.config.hasHooksForEvent('SessionStart')
(!this.config.hasHooksForEvent('SessionStart') &&
!this.config.hasHooksForEvent('FileChanged'))
) {
return undefined;
}
@@ -1419,21 +1419,27 @@ describe('extension tests', () => {
async () => await refresh.promise,
);
const prepared = await prepare(manager, archivePath);
const committed = deferred();
const committedGenerations: number[] = [];
let settled = false;
const committing = manager
.commitPreparedExtension(prepared, (generation) => {
committedGenerations.push(generation);
committed.resolve();
})
.finally(() => {
settled = true;
});
await vi.waitFor(() => expect(committedGenerations).toHaveLength(1));
expect(settled).toBe(false);
refresh.resolve();
await committing;
try {
await Promise.race([committed.promise, committing]);
expect(committedGenerations).toHaveLength(1);
expect(settled).toBe(false);
} finally {
refresh.resolve();
await committing;
}
});
it('fully validates the staged extension before commit', async () => {
@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'node:events';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileChangedWatcher } from './file-changed-watcher.js';
const { watch } = vi.hoisted(() => ({ watch: vi.fn() }));
vi.mock('chokidar', () => ({ watch }));
function nativeWatcher() {
return Object.assign(new EventEmitter(), {
close: vi.fn().mockResolvedValue(undefined),
});
}
describe('FileChangedWatcher lifecycle', () => {
let watcher: FileChangedWatcher;
let nativeWatchers: Array<ReturnType<typeof nativeWatcher>>;
const root = path.resolve('retained');
beforeEach(() => {
nativeWatchers = [];
watch.mockReset().mockImplementation(() => {
const next = nativeWatcher();
nativeWatchers.push(next);
return next;
});
});
afterEach(async () => {
await watcher?.close();
});
it('bounds dynamic traversal while preserving explicit paths and discovery ancestors', async () => {
watcher = new FileChangedWatcher(vi.fn().mockResolvedValue(undefined));
const explicitRoot = path.join(root, 'node_modules', 'selected');
const explicitFile = path.join(root, '.git', 'HEAD');
const registration = watcher.setPaths(
[root, explicitRoot, explicitFile],
[explicitFile],
);
await vi.waitFor(() => expect(watch).toHaveBeenCalledOnce());
const ignored = watch.mock.calls[0][1].ignored;
const file = { isFile: () => true, isDirectory: () => false };
const directory = { isFile: () => false, isDirectory: () => true };
for (const candidate of [
path.join(root, '.git', 'objects', 'pack'),
path.join(root, 'node_modules', 'other', 'index.js'),
path.join(root, 'deep', 'node_modules', 'package'),
path.join(root, 'deep', '.git'),
`${root}-other`,
]) {
expect(ignored(candidate)).toBe(true);
expect(ignored(candidate, directory)).toBe(true);
}
for (const candidate of [
root,
path.dirname(root),
path.join(root, 'first', 'second', 'third', 'topic.md'),
path.join(root, '.github', 'workflows', 'ci.yml'),
path.join(root, 'node_modules-old', 'topic.md'),
path.dirname(explicitFile),
explicitFile,
path.dirname(explicitRoot),
explicitRoot,
path.join(explicitRoot, 'lib', 'index.js'),
]) {
expect(ignored(candidate)).toBe(false);
expect(ignored(candidate, file)).toBe(false);
}
expect(ignored(explicitFile, directory)).toBe(true);
expect(
ignored(path.join(root, 'socket'), {
isFile: () => false,
isDirectory: () => false,
}),
).toBe(true);
nativeWatchers[0].emit('ready');
await registration;
});
it.each(['before ready', 'after ready'])(
'retries unchanged paths after an error %s',
async (timing) => {
watcher = new FileChangedWatcher(vi.fn().mockResolvedValue(undefined));
const registration = watcher.setPaths([root]);
await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(1));
if (timing === 'after ready') {
nativeWatchers[0].emit('ready');
await registration;
}
nativeWatchers[0].emit('error', new Error('watch limit exceeded'));
await registration;
const retry = watcher.setPaths([root]);
await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(2));
nativeWatchers[1].emit('ready');
await retry;
expect(nativeWatchers[0].close).toHaveBeenCalledOnce();
nativeWatchers[0].emit('error', new Error('late error from old watcher'));
await watcher.setPaths([root]);
expect(watch).toHaveBeenCalledTimes(2);
},
);
it('closes a failed watcher when clearing its paths', async () => {
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
const registration = watcher.setPaths([root]);
await vi.waitFor(() => expect(watch).toHaveBeenCalledOnce());
nativeWatchers[0].emit('error', new Error('watch limit exceeded'));
await registration;
await watcher.setPaths([]);
expect(nativeWatchers[0].close).toHaveBeenCalledOnce();
nativeWatchers[0].emit('all', 'change', path.join(root, 'late.md'));
expect(notify).not.toHaveBeenCalled();
});
it('settles registration when closing before the native ready event', async () => {
watcher = new FileChangedWatcher(vi.fn().mockResolvedValue(undefined));
const registration = watcher.setPaths([root]);
await vi.waitFor(() => expect(watch).toHaveBeenCalledOnce());
await Promise.all([registration, watcher.close()]);
await watcher.setPaths([root]);
expect(nativeWatchers[0].close).toHaveBeenCalledOnce();
expect(watch).toHaveBeenCalledOnce();
});
it('preserves queued events in retained roots when a callback reconfigures paths', async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const removed = path.resolve('removed');
const added = path.resolve('added');
const notify = vi
.fn()
.mockImplementationOnce(async () => {
await gate;
await watcher.setPaths([root, added]);
})
.mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
try {
const registration = watcher.setPaths([root, removed]);
await vi.waitFor(() => expect(watch).toHaveBeenCalledOnce());
nativeWatchers[0].emit('ready');
await registration;
const running = path.join(root, 'running.md');
const queued = path.join(root, 'queued.md');
nativeWatchers[0].emit('all', 'change', running);
nativeWatchers[0].emit('all', 'change', queued);
nativeWatchers[0].emit('all', 'change', path.join(removed, 'dropped.md'));
nativeWatchers[0].emit('all', 'change', `${root}-other/dropped.md`);
expect(notify).toHaveBeenCalledTimes(1);
release();
await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(2));
nativeWatchers[1].emit('ready');
await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(2));
expect(notify.mock.calls.map(([filePath]) => filePath)).toEqual([
running,
queued,
]);
} finally {
release();
}
});
});
@@ -0,0 +1,763 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { FSWatcher } from 'chokidar';
import { FileChangedWatcher } from './file-changed-watcher.js';
const waitOptions = { timeout: 5000, interval: 25 };
describe('FileChangedWatcher with real files', () => {
let root: string;
let watcher: FileChangedWatcher;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'file-changed-watcher-'));
});
afterEach(async () => {
await watcher?.close();
vi.restoreAllMocks();
await fs.rm(root, { recursive: true, force: true });
});
it('does not replay the initial scan and reports future nested add, change and unlink', async () => {
await fs.writeFile(path.join(root, 'existing.md'), 'initial');
// Let pending native write events settle before testing scan suppression.
await delay(500);
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([root]);
const nested = path.join(root, 'future', 'nested');
const file = path.join(nested, 'topic.md');
await fs.mkdir(nested, { recursive: true });
await fs.writeFile(file, 'created');
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(file, 'add', expect.any(AbortSignal));
}, waitOptions);
await fs.writeFile(file, 'updated');
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(
file,
'change',
expect.any(AbortSignal),
);
}, waitOptions);
await fs.unlink(file);
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(
file,
'unlink',
expect.any(AbortSignal),
);
}, waitOptions);
expect(notify.mock.calls.map(([filePath]) => filePath)).toEqual([
file,
file,
file,
]);
});
it('replaces watched paths, retains a supplied static file and clears all paths', async () => {
const first = path.join(root, 'first');
const second = path.join(root, 'second');
const staticFile = path.join(root, 'static.txt');
await Promise.all([fs.mkdir(first), fs.mkdir(second)]);
await fs.writeFile(staticFile, 'initial');
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([first, staticFile]);
await watcher.setPaths([second, staticFile]);
await watcher.setPaths([staticFile, second, second]);
await fs.writeFile(path.join(first, 'ignored.md'), 'ignored');
await fs.writeFile(staticFile, 'updated');
await fs.writeFile(path.join(second, 'included.md'), 'included');
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(
staticFile,
'change',
expect.any(AbortSignal),
);
expect(notify).toHaveBeenCalledWith(
path.join(second, 'included.md'),
'add',
expect.any(AbortSignal),
);
}, waitOptions);
await delay(300);
expect(notify.mock.calls).toHaveLength(2);
await watcher.setPaths([]);
notify.mockClear();
await fs.writeFile(staticFile, 'after clearing');
await fs.writeFile(path.join(second, 'after-clear.md'), 'after clearing');
await delay(400);
expect(notify).not.toHaveBeenCalled();
});
it('ignores implicit VCS and dependency subtrees while observing ordinary nested files', async () => {
const excluded = [
path.join(root, '.git', 'objects', 'pack'),
path.join(root, 'node_modules', 'package', 'index.js'),
];
for (const file of excluded) {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, 'initial');
}
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([root]);
for (const file of excluded) await fs.writeFile(file, 'updated');
const nested = path.join(root, 'memory', 'deep', 'topic.md');
await fs.mkdir(path.dirname(nested), { recursive: true });
await fs.writeFile(nested, 'included');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
nested,
'add',
expect.any(AbortSignal),
),
waitOptions,
);
await delay(400);
expect(notify.mock.calls.map(([file]) => file)).toEqual([nested]);
});
it('ignores atomic-write temporary siblings, including delayed renames and orphans', async () => {
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([root]);
const target = path.join(root, 'topic.md');
const temporary = `${target}.a1b2c3d4e5f6.tmp`;
const orphan = path.join(root, 'orphan.md.0123456789ab.tmp');
const ordinary = path.join(root, 'ordinary.tmp');
const differentSuffix = path.join(root, 'topic.md.0123456789abc.tmp');
for (const file of [temporary, orphan, ordinary, differentSuffix]) {
await fs.writeFile(file, 'created');
}
await vi.waitFor(() => {
for (const file of [ordinary, differentSuffix]) {
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
);
}
}, waitOptions);
await delay(700);
expect(notify.mock.calls.map(([file]) => file).sort()).toEqual(
[ordinary, differentSuffix].sort(),
);
await fs.rename(temporary, target);
await fs.unlink(orphan);
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(
target,
'add',
expect.any(AbortSignal),
);
}, waitOptions);
await delay(300);
expect(notify.mock.calls.map(([file]) => file).sort()).toEqual(
[ordinary, differentSuffix, target].sort(),
);
});
it.each(['static', 'dynamic'])(
'preserves %s temporary-file registrations and similarly named directories',
async (registration) => {
const explicit = path.join(root, 'selected.md.a1b2c3d4e5f6.tmp');
const nested = path.join(root, 'directory.a1b2c3d4e5f6.tmp', 'topic.md');
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths(
[root, explicit],
registration === 'static' ? [explicit] : [],
);
await fs.mkdir(path.dirname(nested));
for (const file of [explicit, nested])
await fs.writeFile(file, 'created');
await vi.waitFor(() => {
for (const file of [explicit, nested]) {
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
);
}
}, waitOptions);
expect(notify.mock.calls).toHaveLength(2);
},
);
it('watches both missing static files without traversing siblings and retains dynamic recursion', async () => {
const parent = path.join(root, 'static');
const dynamic = path.join(root, 'dynamic');
await Promise.all([fs.mkdir(parent), fs.mkdir(dynamic)]);
const files = ['.env', '.envrc'].map((name) => path.join(parent, name));
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([...files, dynamic], files);
await fs.writeFile(files[1], 'rc');
await fs.writeFile(files[0], 'env');
await fs.mkdir(path.join(parent, 'unrelated'));
await fs.writeFile(path.join(parent, 'unrelated', 'deep.md'), 'ignored');
await fs.writeFile(path.join(parent, 'other.md'), 'ignored');
await fs.mkdir(path.join(dynamic, 'nested'));
const nested = path.join(dynamic, 'nested', 'topic.md');
await fs.writeFile(nested, 'included');
await vi.waitFor(() => {
for (const file of [...files, nested]) {
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
);
}
}, waitOptions);
await delay(300);
expect(notify.mock.calls.map(([file]) => file).sort()).toEqual(
[...files, nested].sort(),
);
});
it('reconfigures when a static-only path becomes a recursive dynamic root', async () => {
const directory = path.join(root, 'role');
await fs.mkdir(directory);
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([directory], [directory]);
await fs.writeFile(path.join(directory, 'ignored.md'), 'ignored');
await delay(400);
expect(notify).not.toHaveBeenCalled();
await watcher.setPaths([directory]);
const file = path.join(directory, 'included.md');
await fs.writeFile(file, 'included');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
),
waitOptions,
);
});
it('discovers missing dynamic siblings and future nested files without observing unrelated paths', async () => {
const files = ['.env', '.envrc'].map((name) => path.join(root, name));
const directory = path.join(root, 'future');
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([...files, directory]);
await fs.writeFile(files[1], 'rc');
await fs.writeFile(files[0], 'env');
await fs.mkdir(path.join(directory, 'nested'), { recursive: true });
const nested = path.join(directory, 'nested', 'topic.md');
await fs.writeFile(nested, 'included');
await fs.mkdir(path.join(root, 'unrelated'));
await fs.writeFile(path.join(root, 'unrelated', 'deep.md'), 'ignored');
await fs.writeFile(path.join(root, 'other.md'), 'ignored');
await vi.waitFor(() => {
for (const file of [...files, nested]) {
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
);
}
}, waitOptions);
await delay(300);
expect(notify.mock.calls.map(([file]) => file).sort()).toEqual(
[...files, nested].sort(),
);
});
it('discovers a dynamic root with multiple missing ancestors', async () => {
const directory = path.join(root, 'first', 'second', 'third', 'memory');
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([directory]);
await fs.mkdir(directory, { recursive: true });
const file = path.join(directory, 'topic.md');
await fs.writeFile(file, 'new memory');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
),
waitOptions,
);
});
it('retries a path whose discovery parent was not a directory', async () => {
const parent = path.join(root, 'blocked');
await fs.writeFile(parent, 'not a directory');
const directory = path.join(parent, 'memory');
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([directory]);
await fs.unlink(parent);
await fs.mkdir(directory, { recursive: true });
await watcher.setPaths([directory]);
const file = path.join(directory, 'topic.md');
await fs.writeFile(file, 'retry');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
),
waitOptions,
);
});
it('keeps observing a file when its discovery parent has an unreadable parent', async () => {
const parent = path.join(root, 'parent');
const anchor = path.join(parent, 'anchor');
const file = path.join(anchor, 'topic.md');
await fs.mkdir(anchor, { recursive: true });
await fs.writeFile(file, 'initial');
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await fs.chmod(parent, 0o111);
try {
await watcher.setPaths([file], [file]);
await fs.writeFile(file, 'changed');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'change',
expect.any(AbortSignal),
),
waitOptions,
);
} finally {
await fs.chmod(parent, 0o700);
}
});
it('keeps observing a file below a symlinked ancestor', async () => {
const directory = path.join(root, 'real');
const alias = path.join(root, 'alias');
await fs.mkdir(path.join(directory, 'child'), { recursive: true });
await fs.symlink(
directory,
alias,
process.platform === 'win32' ? 'junction' : 'dir',
);
const file = path.join(alias, 'child', 'topic.md');
await fs.writeFile(file, 'initial');
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([file], [file]);
await fs.writeFile(file, 'changed');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'change',
expect.any(AbortSignal),
),
waitOptions,
);
});
it.each(['static', 'dynamic'])(
'reports an external file through each %s symlink alias',
async (registration) => {
const workspace = path.join(root, 'workspace');
const external = path.join(root, 'external');
await Promise.all([fs.mkdir(workspace), fs.mkdir(external)]);
const target = path.join(external, 'target.md');
const aliases = ['first.md', 'second.md'].map((name) =>
path.join(workspace, name),
);
await fs.writeFile(target, 'initial');
for (const alias of aliases) await fs.symlink(target, alias);
await delay(500);
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
const staticFiles = registration === 'static' ? aliases : [];
await watcher.setPaths(aliases, staticFiles);
await fs.writeFile(target, 'changed through real path');
await vi.waitFor(() => {
for (const alias of aliases) {
expect(notify).toHaveBeenCalledWith(
alias,
'change',
expect.any(AbortSignal),
);
}
}, waitOptions);
await fs.writeFile(path.join(external, 'unrelated.md'), 'ignored');
await delay(400);
expect(notify.mock.calls.map(([file]) => file).sort()).toEqual(aliases);
await watcher.setPaths([aliases[0]], staticFiles.slice(0, 1));
notify.mockClear();
await fs.writeFile(target, 'only the retained alias');
await vi.waitFor(() => {
expect(notify.mock.calls.map(([file]) => file)).toEqual([aliases[0]]);
}, waitOptions);
},
);
it('maps a symlinked directory root without following implicit child links', async () => {
const workspace = path.join(root, 'workspace');
const target = path.join(root, 'external', 'memory');
const unrelated = path.join(root, 'unrelated');
await Promise.all(
[workspace, target, unrelated].map((directory) =>
fs.mkdir(directory, { recursive: true }),
),
);
const alias = path.join(workspace, 'data');
const directoryLinkType = process.platform === 'win32' ? 'junction' : 'dir';
await fs.symlink(target, alias, directoryLinkType);
await fs.symlink(unrelated, path.join(target, 'linked'), directoryLinkType);
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([alias, target]);
for (const relative of [
'.git/config',
'node_modules/package/index.js',
'topic.md.a1b2c3d4e5f6.tmp',
]) {
const file = path.join(target, relative);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, 'ignored');
}
await fs.writeFile(path.join(unrelated, 'linked.md'), 'ignored');
await fs.writeFile(
path.join(path.dirname(target), 'sibling.md'),
'ignored',
);
const relative = path.join('nested', 'topic.md');
await fs.mkdir(path.join(target, 'nested'));
await fs.writeFile(path.join(target, relative), 'included');
const expected = [path.join(alias, relative), path.join(target, relative)];
await vi.waitFor(() => {
for (const file of expected) {
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
);
}
}, waitOptions);
await delay(400);
expect(notify.mock.calls.map(([file]) => file).sort()).toEqual(
expected.sort(),
);
});
it.each(['file', 'directory'])(
'refreshes a symlinked %s target with the same configured path',
async (kind) => {
const workspace = path.join(root, 'workspace');
const first = path.join(root, 'first');
const second = path.join(root, 'second');
await Promise.all([workspace, first, second].map((dir) => fs.mkdir(dir)));
const firstFile = path.join(first, 'topic.md');
const secondFile = path.join(second, 'topic.md');
await fs.writeFile(firstFile, 'initial first');
await fs.writeFile(secondFile, 'initial second');
const alias = path.join(workspace, 'alias');
const linkType =
kind === 'file'
? 'file'
: process.platform === 'win32'
? 'junction'
: 'dir';
await fs.symlink(kind === 'file' ? firstFile : first, alias, linkType);
await delay(500);
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
const staticFiles = kind === 'file' ? [alias] : [];
await watcher.setPaths([alias], staticFiles);
await fs.unlink(alias);
await fs.symlink(kind === 'file' ? secondFile : second, alias, linkType);
await watcher.setPaths([alias], staticFiles);
await fs.writeFile(firstFile, 'old target is no longer watched');
await delay(400);
expect(notify).not.toHaveBeenCalled();
await fs.writeFile(secondFile, 'new target');
const expected = kind === 'file' ? alias : path.join(alias, 'topic.md');
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(
expected,
'change',
expect.any(AbortSignal),
);
}, waitOptions);
expect(notify.mock.calls).toHaveLength(1);
},
);
it('keeps a symlinked file armed while its real target is missing', async () => {
const target = path.join(root, 'target.md');
const alias = path.join(root, 'alias.md');
await fs.writeFile(target, 'initial');
await fs.symlink(target, alias);
await delay(500);
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([alias], [alias]);
await fs.unlink(target);
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(
alias,
'unlink',
expect.any(AbortSignal),
);
}, waitOptions);
await watcher.setPaths([alias], [alias]);
await fs.writeFile(target, 'recreated');
await vi.waitFor(() => {
expect(notify).toHaveBeenCalledWith(
alias,
'add',
expect.any(AbortSignal),
);
}, waitOptions);
});
it('continues observing a root deleted and recreated after a gap', async () => {
const directory = path.join(root, 'memory');
await fs.mkdir(directory);
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([directory]);
const before = path.join(directory, 'before.md');
await fs.writeFile(before, 'before');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
before,
'add',
expect.any(AbortSignal),
),
waitOptions,
);
await fs.rm(directory, { recursive: true });
await delay(200);
await fs.mkdir(directory);
const after = path.join(directory, 'after.md');
await fs.writeFile(after, 'after');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
after,
'add',
expect.any(AbortSignal),
),
waitOptions,
);
});
it.each([
['dynamic', false],
['static', false],
['dynamic', true],
['static', true],
] as const)(
'recovers a %s target when its discovery anchor is deleted and recreated (suppress unlinkDir: %s)',
async (kind, suppressUnlinkDir) => {
const anchor = path.join(root, 'anchor');
const emit = FSWatcher.prototype.emit;
vi.spyOn(FSWatcher.prototype, 'emit').mockImplementation(function (
this: FSWatcher,
event,
...args
) {
if (
suppressUnlinkDir &&
event === 'all' &&
args[0] === 'unlinkDir' &&
args[1] === anchor
) {
return false;
}
return Reflect.apply(emit, this, [event, ...args]);
});
const directory = kind === 'static' ? anchor : path.join(anchor, 'out');
await fs.mkdir(directory, { recursive: true });
const file = path.join(directory, 'topic.md');
await fs.writeFile(file, 'initial');
const target = kind === 'static' ? file : directory;
const staticFiles = kind === 'static' ? [file] : [];
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([target], staticFiles);
await fs.writeFile(file, 'before deletion');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'change',
expect.any(AbortSignal),
),
waitOptions,
);
await fs.rm(anchor, { recursive: true });
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'unlink',
expect.any(AbortSignal),
),
waitOptions,
);
notify.mockClear();
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(file, 'recreated');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'add',
expect.any(AbortSignal),
),
waitOptions,
);
await watcher.setPaths([target], staticFiles);
await fs.writeFile(file, 'after identical refresh');
await vi.waitFor(
() =>
expect(notify).toHaveBeenCalledWith(
file,
'change',
expect.any(AbortSignal),
),
waitOptions,
);
expect(notify.mock.calls.every(([changed]) => changed === file)).toBe(
true,
);
},
);
it('continues delivering after a callback failure', async () => {
const notify = vi
.fn()
.mockRejectedValueOnce(new Error('hook failed'))
.mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([root]);
const first = path.join(root, 'first.md');
const second = path.join(root, 'second.md');
await fs.writeFile(first, 'first');
await vi.waitFor(
() => expect(notify).toHaveBeenCalledTimes(1),
waitOptions,
);
await fs.writeFile(second, 'second');
await vi.waitFor(
() => expect(notify).toHaveBeenCalledTimes(2),
waitOptions,
);
expect(notify.mock.calls.map(([filePath]) => filePath)).toEqual([
first,
second,
]);
});
it('serializes callbacks and aborts the running one while dropping queued events on close', async () => {
let signal: AbortSignal | undefined;
const notify = vi.fn(
async (_file: string, _event: string, currentSignal: AbortSignal) => {
signal = currentSignal;
await new Promise<void>((resolve) =>
currentSignal.addEventListener('abort', () => resolve(), {
once: true,
}),
);
},
);
watcher = new FileChangedWatcher(notify);
await watcher.setPaths([root]);
await fs.writeFile(path.join(root, 'first.md'), 'first');
await vi.waitFor(
() => expect(notify).toHaveBeenCalledTimes(1),
waitOptions,
);
await fs.writeFile(path.join(root, 'second.md'), 'second');
await delay(500);
expect(notify).toHaveBeenCalledTimes(1);
await watcher.close();
expect(signal?.aborted).toBe(true);
await fs.writeFile(path.join(root, 'third.md'), 'third');
await watcher.setPaths([root]);
await delay(400);
expect(notify).toHaveBeenCalledTimes(1);
});
it('discards queued events from removed roots while allowing the running callback to finish', async () => {
const first = path.join(root, 'first');
const second = path.join(root, 'second');
await Promise.all([fs.mkdir(first), fs.mkdir(second)]);
let finishFirst!: () => void;
const firstFinished = new Promise<void>((resolve) => {
finishFirst = resolve;
});
const notify = vi
.fn()
.mockImplementationOnce(() => firstFinished)
.mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
try {
await watcher.setPaths([first]);
await fs.writeFile(path.join(first, 'running.md'), 'first');
await vi.waitFor(
() => expect(notify).toHaveBeenCalledTimes(1),
waitOptions,
);
await fs.writeFile(path.join(first, 'queued.md'), 'queued');
await delay(500);
await watcher.setPaths([second]);
finishFirst();
const replacement = path.join(second, 'replacement.md');
await fs.writeFile(replacement, 'new root');
await vi.waitFor(
() => expect(notify).toHaveBeenCalledTimes(2),
waitOptions,
);
expect(notify.mock.calls.map(([filePath]) => filePath)).toEqual([
path.join(first, 'running.md'),
replacement,
]);
} finally {
finishFirst();
}
});
it('can close during initial registration without hanging or restarting', async () => {
const notify = vi.fn().mockResolvedValue(undefined);
watcher = new FileChangedWatcher(notify);
const registration = watcher.setPaths([root]);
await Promise.all([registration, watcher.close()]);
await watcher.setPaths([root]);
await fs.writeFile(path.join(root, 'after-close.md'), 'after close');
await delay(400);
expect(notify).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,292 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import {
accessSync,
constants,
lstatSync,
readlinkSync,
realpathSync,
statSync,
} from 'node:fs';
import { watch, type FSWatcher } from 'chokidar';
import { closeFileWatcher } from '../utils/file-watcher-cleanup.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import type { FileChangeEvent } from './types.js';
const debugLogger = createDebugLogger('FILE_CHANGED_WATCHER');
function isWithin(root: string, filePath: string): boolean {
const relative = path.relative(root, filePath);
return (
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
function discoveryRoot(filePath: string): string | undefined {
let directory = path.dirname(filePath);
while (true) {
try {
return statSync(directory).isDirectory() ? directory : undefined;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
debugLogger.warn(
`Could not inspect watch directory ${directory}:`,
error,
);
return undefined;
}
const parent = path.dirname(directory);
if (parent === directory) return undefined;
directory = parent;
}
}
}
function observationPath(filePath: string): string {
try {
return realpathSync(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return filePath;
}
try {
return observationPath(
path.resolve(path.dirname(filePath), readlinkSync(filePath)),
);
} catch {
const parent = path.dirname(filePath);
return parent === filePath
? filePath
: path.join(observationPath(parent), path.basename(filePath));
}
}
export class FileChangedWatcher {
private watcher?: FSWatcher;
private paths: string[] = [];
private staticFiles: string[] = [];
private observedPaths: string[] = [];
private readonly lifetime = new AbortController();
private changes: Array<{ filePath: string; event: FileChangeEvent }> = [];
private draining?: Promise<void>;
private reconfiguration: Promise<void> = Promise.resolve();
private settleReady?: () => void;
private closing?: Promise<void>;
constructor(
private readonly notify: (
filePath: string,
event: FileChangeEvent,
signal: AbortSignal,
) => Promise<void>,
) {}
setPaths(
paths: readonly string[],
staticFiles: readonly string[] = [],
): Promise<void> {
const next = [
...new Set(paths.map((filePath) => path.resolve(filePath))),
].sort();
const files = [...new Set(staticFiles.map((file) => path.resolve(file)))]
.filter((file) => next.includes(file))
.sort();
const staticSet = new Set(files);
this.reconfiguration = this.reconfiguration.then(async () => {
if (this.lifetime.signal.aborted) return;
const observed = next.map(observationPath);
const staticTargets = new Set(
observed.filter((_, index) => staticSet.has(next[index])),
);
const dynamic = observed.filter(
(_, index) => !staticSet.has(next[index]),
);
if (
next.length === this.paths.length &&
next.every((filePath, index) => filePath === this.paths[index]) &&
files.length === this.staticFiles.length &&
files.every(
(filePath, index) => filePath === this.staticFiles[index],
) &&
observed.every(
(filePath, index) => filePath === this.observedPaths[index],
) &&
(next.length > 0 || !this.watcher)
) {
return;
}
await this.stopWatcher(next, staticSet);
if (this.lifetime.signal.aborted) return;
this.paths = next;
this.staticFiles = files;
this.observedPaths = observed;
if (next.length === 0) return;
try {
// Observe surviving ancestors so missing or replaced roots can reappear.
const anchors = observed.map(discoveryRoot);
if (anchors.some((root) => root === undefined)) this.paths = [];
// Keep a ready parent observer when an anchor's unlinkDir is missed.
const watchRoots = [
...new Set(
anchors
.filter((root) => root !== undefined)
.map((root) => {
const parent = discoveryRoot(root);
if (!parent) return root;
try {
if (!lstatSync(parent).isDirectory()) return root;
accessSync(parent, constants.R_OK);
return parent;
} catch {
return root;
}
}),
),
];
if (watchRoots.length === 0) return;
const watcher = watch(watchRoots, {
ignoreInitial: true,
ignored: (candidate, stats) => {
if (stats && !stats.isFile() && !stats.isDirectory()) return true;
const file = path.resolve(candidate);
if (
stats?.isFile() &&
/\.[0-9a-f]{12}\.tmp$/.test(path.basename(file)) &&
!observed.includes(file)
)
return true;
if (
dynamic.some(
(root) =>
isWithin(root, file) &&
!path
.relative(root, file)
.split(path.sep)
.some((part) => part === '.git' || part === 'node_modules'),
)
)
return false;
if (staticTargets.has(file)) return stats?.isDirectory() ?? false;
// Only ancestors of requested paths may lead discovery downward.
return !observed.some((root) => isWithin(file, root));
},
followSymlinks: false,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
});
this.watcher = watcher;
await new Promise<void>((resolve) => {
this.settleReady = resolve;
watcher
.on('all', (event, filePath) => {
if (this.lifetime.signal.aborted || this.watcher !== watcher) {
return;
}
const resolved = path.resolve(filePath);
if (event === 'unlinkDir' && watchRoots.includes(resolved)) {
this.paths = [];
const parent = discoveryRoot(resolved);
if (parent && !watchRoots.includes(parent)) {
watchRoots.push(parent);
// Keep the watcher alive so pending file unlinks still drain.
watcher.add(parent);
}
return;
}
if (event !== 'add' && event !== 'change' && event !== 'unlink')
return;
const aliases = next.flatMap((alias, index) => {
const target = observed[index];
if (
staticSet.has(alias)
? resolved !== target
: !isWithin(target, resolved)
)
return [];
return [path.join(alias, path.relative(target, resolved))];
});
for (const filePath of new Set(aliases)) {
this.changes.push({ filePath, event });
}
this.drain();
})
.on('error', (error) => {
if (this.watcher === watcher) this.paths = [];
debugLogger.warn('File watcher failed:', error);
resolve();
})
.once('ready', resolve);
});
this.settleReady = undefined;
} catch (error) {
this.paths = [];
debugLogger.warn('Could not start file watcher:', error);
}
});
return this.reconfiguration;
}
close(): Promise<void> {
if (this.closing) return this.closing;
this.lifetime.abort();
this.changes = [];
this.settleReady?.();
this.closing = (async () => {
await this.reconfiguration;
await this.stopWatcher();
await this.draining;
})();
return this.closing;
}
private async stopWatcher(
retainedPaths: readonly string[] = [],
staticFiles: ReadonlySet<string> = new Set(),
): Promise<void> {
this.changes = this.changes.filter(({ filePath }) =>
retainedPaths.some((root) =>
staticFiles.has(root) ? root === filePath : isWithin(root, filePath),
),
);
const watcher = this.watcher;
this.watcher = undefined;
// macOS exit cleanup may leave the native watcher open; keep its error handler.
watcher?.removeAllListeners('all');
watcher?.removeAllListeners('ready');
try {
await closeFileWatcher(watcher);
} catch (error) {
debugLogger.warn('Could not close file watcher:', error);
}
}
private drain(): void {
if (this.draining) return;
this.draining = (async () => {
while (!this.lifetime.signal.aborted) {
const change = this.changes.shift();
if (!change) break;
try {
await this.notify(
change.filePath,
change.event,
this.lifetime.signal,
);
} catch (error) {
if (!this.lifetime.signal.aborted) {
debugLogger.warn('FileChanged hook failed:', error);
}
}
}
})().finally(() => {
this.draining = undefined;
if (this.changes.length > 0 && !this.lifetime.signal.aborted)
this.drain();
});
}
}
@@ -21,7 +21,7 @@ const DEDICATED_MESSAGE_EVENTS = [
describe('HOOK_EVENT_DISPLAY', () => {
it('classifies every hook event and nothing else', () => {
expect(allEvents).toHaveLength(22);
expect(allEvents).toHaveLength(23);
expect(Object.keys(HOOK_EVENT_DISPLAY).sort()).toEqual(
[...allEvents].sort(),
);
@@ -72,6 +72,7 @@ export const HOOK_EVENT_DISPLAY: Readonly<
[HookEventName.PermissionDenied]: ORDINARY,
[HookEventName.StopFailure]: ORDINARY,
[HookEventName.InstructionsLoaded]: ORDINARY,
[HookEventName.FileChanged]: ORDINARY,
});
/**
+1 -1
View File
@@ -24,7 +24,7 @@ export interface HookPatternOptions {
* pairs: in `C:\\temp\\|D:\\data` the pipe follows an escaped backslash, so it
* does separate entries.
*/
function splitListEntries(pattern: string): string[] {
export function splitListEntries(pattern: string): string[] {
const entries: string[] = [];
let current = '';
let escaped = false;
@@ -53,6 +53,42 @@ describe('HookAggregator', () => {
createHookOutput(event, aggregate(event, outputs).finalOutput ?? {}) as T;
describe('aggregateResults', () => {
it.each([HookEventName.SessionStart, HookEventName.FileChanged])(
'ignores watchPaths from a failed %s hook without mutating its other fields',
(eventName) => {
const failed: HookOutput = {
reason: 'failed',
hookSpecificOutput: {
hookEventName: eventName,
watchPaths: ['/failed'],
additionalContext: 'kept',
},
};
const result = aggregateAll(
eventName,
{
output: {
hookSpecificOutput: {
hookEventName: eventName,
watchPaths: ['/valid'],
},
},
},
{ success: false, output: failed },
);
expect(result.allOutputs[0].hookSpecificOutput?.['watchPaths']).toEqual(
['/valid'],
);
expect(result.allOutputs[1]).toEqual({
reason: 'failed',
hookSpecificOutput: {
hookEventName: eventName,
additionalContext: 'kept',
},
});
expect(failed.hookSpecificOutput?.['watchPaths']).toEqual(['/failed']);
},
);
it('should return undefined finalOutput when no results', () => {
const result = aggregator.aggregateResults([], HookEventName.PreToolUse);
expect(result.success).toBe(true);
+12 -1
View File
@@ -85,7 +85,18 @@ export class HookAggregator {
}
if (result.output) {
allOutputs.push(result.output);
if (
!result.success &&
(eventName === HookEventName.SessionStart ||
eventName === HookEventName.FileChanged) &&
result.output.hookSpecificOutput
) {
const specific = { ...result.output.hookSpecificOutput };
delete specific['watchPaths'];
allOutputs.push({ ...result.output, hookSpecificOutput: specific });
} else {
allOutputs.push(result.output);
}
}
}
@@ -368,6 +368,25 @@ describe('HookEventHandler', () => {
});
});
describe('fireFileChangedEvent', () => {
it.each(['add', 'change', 'unlink'] as const)(
'includes the observed path and %s event',
async (event) => {
const input = await captureInput(() =>
hookEventHandler.fireFileChangedEvent(
'/outside/memory/topic.md',
event,
),
);
expectPlanned(HookEventName.FileChanged, {
filePath: '/outside/memory/topic.md',
});
expectFields(input, { file_path: '/outside/memory/topic.md', event });
expect(input).not.toHaveProperty('tool_use_id');
},
);
});
describe('fireUserPromptExpansionEvent', () => {
const fire = () =>
hookEventHandler.fireUserPromptExpansionEvent(
@@ -57,6 +57,8 @@ import type {
InstructionLoadReason,
BackgroundTaskInfo,
CronJobInfo,
FileChangedInput,
FileChangeEvent,
} from './types.js';
import {
createHookOutput,
@@ -217,6 +219,24 @@ export class HookEventHandler {
/** Optional provider for conversation history */
private messagesProvider?: MessagesProvider;
async fireFileChangedEvent(
filePath: string,
event: FileChangeEvent,
signal?: AbortSignal,
): Promise<AggregatedHookResult> {
const input: FileChangedInput = {
...this.createBaseInput(HookEventName.FileChanged),
file_path: filePath,
event,
};
return this.executeHooks(
HookEventName.FileChanged,
input,
{ filePath },
signal,
);
}
constructor(
config: Config,
hookPlanner: HookPlanner,
@@ -131,6 +131,14 @@ describe('HookPlanner', () => {
).toEqual({ kind: 'filePath', target: '/repo/.qwen/QWEN.local.md' });
});
it('matches FileChanged using the basename without the watched directory', () => {
expect(
getHookMatcherTarget(HookEventName.FileChanged, {
filePath: '/outside/memory/topic.md',
}),
).toEqual({ kind: 'filePath', target: 'topic.md' });
});
it('returns command name targets for user prompt expansion events', () => {
expect(
getHookMatcherTarget(UserPromptExpansion, { commandName: 'goal' }),
+7
View File
@@ -5,6 +5,7 @@
*/
import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js';
import path from 'node:path';
import type { HookExecutionOwner } from './hook-execution-context.js';
import type { HookExecutionPlan } from './types.js';
import { getHookKey, HookEventName } from './types.js';
@@ -79,6 +80,12 @@ export function getHookMatcherTarget(
case HookEventName.InstructionsLoaded:
return { kind: 'filePath', target: context?.filePath ?? '' };
case HookEventName.FileChanged:
return {
kind: 'filePath',
target: path.basename(context?.filePath ?? ''),
};
case HookEventName.UserPromptExpansion:
// Unlike UserPromptSubmit, command expansions are matchable by the slash
// command name that produced the submitted prompt.
+618
View File
@@ -45,12 +45,24 @@ import * as hookOutputs from './types.js';
import type { Config } from '../config/config.js';
import type { AggregatedHookResult } from './hookAggregator.js';
import type { HookOutput } from './types.js';
import path from 'node:path';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { FileChangedWatcher } from './file-changed-watcher.js';
import { applySkillHooks } from '../tools/skill-utils.js';
import { getHookExecutionOwner } from './hook-execution-context.js';
import {
runWithInvocationContext,
getInvocationContext,
} from '../utils/invocation-context.js';
import { promptIdContext } from '../utils/promptIdContext.js';
vi.mock('./hookRegistry.js');
vi.mock('./hookRunner.js');
vi.mock('./hookAggregator.js');
vi.mock('./hookPlanner.js');
vi.mock('./hookEventHandler.js');
vi.mock('./file-changed-watcher.js');
const aggregated = (
finalOutput?: HookOutput,
@@ -125,6 +137,7 @@ describe('HookSystem', () => {
mockHookEventHandler = {
fireUserPromptSubmitEvent: vi.fn(),
fireInstructionsLoadedEvent: vi.fn(),
fireFileChangedEvent: vi.fn(),
fireUserPromptExpansionEvent: vi.fn(),
fireStopEvent: vi.fn(),
fireMessageDisplayEvent: vi.fn(),
@@ -151,6 +164,13 @@ describe('HookSystem', () => {
vi.mocked(HookAggregator).mockImplementation(() => mockHookAggregator);
vi.mocked(HookPlanner).mockImplementation(() => mockHookPlanner);
vi.mocked(HookEventHandler).mockImplementation(() => mockHookEventHandler);
vi.mocked(FileChangedWatcher).mockImplementation(
() =>
({
setPaths: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
}) as unknown as FileChangedWatcher,
);
hookSystem = new HookSystem(mockConfig);
});
@@ -166,6 +186,604 @@ describe('HookSystem', () => {
};
afterEach(() => vi.restoreAllMocks());
describe('FileChanged registration', () => {
const watchOutput = (watchPaths: unknown): HookOutput => ({
hookSpecificOutput: { hookEventName: 'SessionStart', watchPaths },
});
const start = (allOutputs: HookOutput[] = []) =>
fire('fireSessionStartEvent', [SessionStartSource.Startup, 'model'], {
...aggregated(),
allOutputs,
});
const watcher = () =>
vi.mocked(FileChangedWatcher).mock.results.at(-1)!
.value as FileChangedWatcher;
beforeEach(() => {
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([
{ ...registryEntry(HookEventName.FileChanged), matcher: '.env|.envrc' },
]);
});
it('does not derive recursive directory watches from static matchers', async () => {
const directory = mkdtempSync(path.join(tmpdir(), 'hook-static-'));
try {
mkdirSync(path.join(directory, 'node_modules'));
vi.mocked(mockConfig.getWorkingDir).mockReturnValue(directory);
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([
{
...registryEntry(HookEventName.FileChanged),
matcher: 'node_modules|future.md',
},
]);
await hookSystem.initialize();
expect(watcher().setPaths).toHaveBeenCalledWith(
[path.join(directory, 'future.md')],
[path.join(directory, 'future.md')],
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it.each([
'(notes|todo|other)\\.md',
'^notes|todo.md',
'(notes)|todo.md',
'**.md',
'[abc].env',
'notes\\|todo\\.md',
])('does not derive static paths from regex %s', async (matcher) => {
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([
{ ...registryEntry(HookEventName.FileChanged), matcher },
]);
await hookSystem.initialize();
expect(FileChangedWatcher).not.toHaveBeenCalled();
});
it('relocates static paths without losing explicit dynamic roots', async () => {
await hookSystem.initialize();
await start([watchOutput(['/explicit/root'])]);
vi.mocked(mockConfig.getWorkingDir).mockReturnValue('/new/cwd');
await hookSystem.refreshFileWatchingForWorkingDirectory();
expect(watcher().setPaths).toHaveBeenLastCalledWith(
[
'/explicit/root',
path.resolve('/new/cwd', '.env'),
path.resolve('/new/cwd', '.envrc'),
],
[path.resolve('/new/cwd', '.env'), path.resolve('/new/cwd', '.envrc')],
);
});
it('gives normalized dynamic paths precedence over static file roles', async () => {
await start([watchOutput(['/test/cwd/sub/../.env'])]);
expect(watcher().setPaths).toHaveBeenLastCalledWith(
[
'/test/cwd/sub/../.env',
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[path.resolve('/test/cwd', '.envrc')],
);
});
it('does not start bootstrap watching during cwd refresh', async () => {
await hookSystem.initialize({ skipFileWatching: true });
vi.mocked(mockConfig.getWorkingDir).mockReturnValue('/new/cwd');
await hookSystem.refreshFileWatchingForWorkingDirectory();
expect(FileChangedWatcher).not.toHaveBeenCalled();
});
it('starts static paths during initialization without any SessionStart hook', async () => {
await hookSystem.initialize();
expect(watcher().setPaths).toHaveBeenCalledWith(
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
);
expect(mockHookEventHandler.fireSessionStartEvent).not.toHaveBeenCalled();
});
it('loads bootstrap hooks without starting watches, including after reload', async () => {
await hookSystem.initialize({ skipFileWatching: true });
await hookSystem.reload();
expect(mockHookRegistry.initialize).toHaveBeenCalledOnce();
expect(FileChangedWatcher).not.toHaveBeenCalled();
await start();
expect(watcher().setPaths).toHaveBeenCalledWith(
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
);
});
it('refreshes static paths when a skill registers directly with the session manager', async () => {
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([]);
await hookSystem.initialize();
applySkillHooks(
{
getHookSystem: () => hookSystem,
getSessionId: () => 'test-session-id',
},
{
name: 'watch-topic',
description: 'Watch topic',
level: 'user',
filePath: '/skills/watch-topic/SKILL.md',
skillRoot: '/skills/watch-topic',
body: '',
hooks: {
FileChanged: [
{
matcher: 'topic.md',
hooks: [{ type: HookType.Command, command: 'echo changed' }],
},
],
},
},
);
await vi.waitFor(() => {
expect(watcher().setPaths).toHaveBeenLastCalledWith(
[path.resolve('/test/cwd', 'topic.md')],
[path.resolve('/test/cwd', 'topic.md')],
);
});
const manager = hookSystem.getSessionHooksManager();
const [entry] = manager.getHooksForEvent(
'test-session-id',
HookEventName.FileChanged,
);
manager.removeHook('test-session-id', entry.hookId);
await vi.waitFor(() =>
expect(watcher().setPaths).toHaveBeenLastCalledWith([], []),
);
});
it('keeps watchPaths from an earlier output when a later hook only adds context', async () => {
const context = {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: 'context',
},
};
const output = await fire(
'fireSessionStartEvent',
[SessionStartSource.Startup, 'model'],
{
...aggregated(context),
allOutputs: [watchOutput(['/memory']), context],
},
);
expect(output?.hookSpecificOutput?.['additionalContext']).toBe('context');
expect(watcher().setPaths).toHaveBeenCalledWith(
[
'/memory',
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
);
});
it('uses the last valid list and rejects an entire invalid list', async () => {
await start([
watchOutput(['/first']),
watchOutput(['/second']),
watchOutput(['/partial', 'relative']),
]);
expect(watcher().setPaths).toHaveBeenLastCalledWith(
[
'/second',
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
);
});
it('clears dynamic paths with [] while retaining static paths', async () => {
await start([watchOutput(['/memory'])]);
await fire('fireFileChangedEvent', ['/memory/topic.md', 'change'], {
...aggregated(),
allOutputs: [
{
hookSpecificOutput: {
hookEventName: 'FileChanged',
watchPaths: [],
},
},
],
});
expect(watcher().setPaths).toHaveBeenLastCalledWith(
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
);
});
it('does not interpret match-all or path matchers as a workspace scan', async () => {
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(
['', '*', '.*', '../secret', '.', '..'].map((matcher) => ({
...registryEntry(HookEventName.FileChanged),
matcher,
})),
);
await start();
expect(FileChangedWatcher).not.toHaveBeenCalled();
});
it.each([
'remote',
'sandbox',
'managed',
'managed-engine',
'disabled',
] as const)(
'does not create a local watcher for %s sessions',
async (kind) => {
Object.assign(mockConfig, {
getExecutionEnvironment: () => (kind === 'remote' ? {} : undefined),
getSessionExecutionEngine: () =>
kind === 'managed-engine' ? 'managed' : undefined,
getShellExecutionSandbox: () => (kind === 'sandbox' ? {} : undefined),
getDisableAllHooks: () => kind === 'disabled',
});
if (kind === 'managed')
hookSystem = new HookSystem(mockConfig, {
hasHooksForEvent: () => true,
execute: async () => aggregated(),
});
await start([watchOutput(['/memory'])]);
expect(FileChangedWatcher).not.toHaveBeenCalled();
},
);
it('binds observed changes to the listening session without a stale prompt or invocation', async () => {
await start([watchOutput(['/memory'])]);
const observations: unknown[] = [];
vi.mocked(mockHookEventHandler.fireFileChangedEvent).mockImplementation(
async () => {
observations.push(
getHookExecutionOwner(),
getInvocationContext(),
promptIdContext.getStore(),
);
return aggregated();
},
);
const callback = vi.mocked(FileChangedWatcher).mock.calls[0][0];
await runWithInvocationContext(
{ version: 1, sessionId: 'other', promptId: 'old' },
() =>
promptIdContext.run('old', () =>
callback('/memory/topic.md', 'add', new AbortController().signal),
),
);
expect(observations).toEqual([
{
runtimeId: hookSystem.runtimeId,
sessionId: 'test-session-id',
agentId: null,
},
undefined,
undefined,
]);
});
it('disposes the watcher and does not restart after shutdown', async () => {
await start([watchOutput(['/memory'])]);
const active = watcher();
await hookSystem.close();
await start([watchOutput(['/later'])]);
expect(active.close).toHaveBeenCalledOnce();
expect(FileChangedWatcher).toHaveBeenCalledOnce();
});
it('drops static registrations on disable and reload', async () => {
await start();
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([]);
hookSystem.setHookEnabled('test-hook', false);
await vi.waitFor(() =>
expect(watcher().setPaths).toHaveBeenLastCalledWith([], []),
);
await hookSystem.reload();
expect(watcher().setPaths).toHaveBeenLastCalledWith([], []);
});
it('rebinds the watcher on session rotation and discards outgoing dynamic paths', async () => {
await start([watchOutput(['/old-memory'])]);
const oldWatcher = watcher();
vi.mocked(mockConfig.getSessionId).mockReturnValue('new-session');
await start();
expect(oldWatcher.close).toHaveBeenCalledOnce();
expect(FileChangedWatcher).toHaveBeenCalledTimes(2);
expect(watcher().setPaths).toHaveBeenCalledWith(
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
);
});
it.each(['reload', 'skill', 'enable', 'cwd'] as const)(
'starts later FileChanged hooks after session rotation through %s',
async (refresh) => {
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([]);
await hookSystem.initialize();
await fire('fireSessionEndEvent', [SessionEndReason.Clear]);
vi.mocked(mockConfig.getSessionId).mockReturnValue('new-session');
if (refresh === 'skill') {
hookSystem
.getSessionHooksManager()
.addSessionHook(
'new-session',
HookEventName.FileChanged,
'notes.md',
{ type: HookType.Command, command: 'echo changed' },
);
} else {
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([
{
...registryEntry(HookEventName.FileChanged),
matcher: 'notes.md',
},
]);
if (refresh === 'reload') await hookSystem.reload();
if (refresh === 'enable')
hookSystem.setHookEnabled('test-hook', true);
if (refresh === 'cwd')
await hookSystem.refreshFileWatchingForWorkingDirectory();
}
await vi.waitFor(() => {
expect(FileChangedWatcher).toHaveBeenCalledOnce();
expect(watcher().setPaths).toHaveBeenCalledWith(
[path.resolve('/test/cwd', 'notes.md')],
[path.resolve('/test/cwd', 'notes.md')],
);
});
expect(
mockHookEventHandler.fireSessionStartEvent,
).not.toHaveBeenCalled();
},
);
it('rebinds on reload without SessionEnd and ignores outgoing FileChanged results', async () => {
await start([watchOutput(['/old-memory'])]);
const outgoing = watcher();
let finish!: (result: AggregatedHookResult) => void;
vi.mocked(mockHookEventHandler.fireFileChangedEvent).mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const changed = hookSystem.fireFileChangedEvent(
'/old-memory/topic.md',
'change',
);
vi.mocked(mockConfig.getSessionId).mockReturnValue('new-session');
await hookSystem.reload();
finish({
...aggregated(),
allOutputs: [
{
hookSpecificOutput: {
hookEventName: 'FileChanged',
watchPaths: ['/outgoing-result'],
},
},
],
});
await changed;
expect(outgoing.close).toHaveBeenCalledOnce();
expect(outgoing.setPaths).toHaveBeenCalledOnce();
expect(FileChangedWatcher).toHaveBeenCalledTimes(2);
expect(watcher().setPaths).toHaveBeenCalledOnce();
expect(watcher().setPaths).toHaveBeenLastCalledWith(
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
[
path.resolve('/test/cwd', '.env'),
path.resolve('/test/cwd', '.envrc'),
],
);
});
it('keeps the ended session stopped when hooks change before rotation', async () => {
await start();
const outgoing = watcher();
await fire('fireSessionEndEvent', [SessionEndReason.Clear]);
await hookSystem.reload();
await hookSystem.refreshFileWatchingForWorkingDirectory();
hookSystem.setHookEnabled('test-hook', true);
hookSystem
.getSessionHooksManager()
.addSessionHook(
'test-session-id',
HookEventName.FileChanged,
'notes.md',
{ type: HookType.Command, command: 'echo changed' },
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(outgoing.close).toHaveBeenCalledOnce();
expect(outgoing.setPaths).toHaveBeenCalledOnce();
expect(FileChangedWatcher).toHaveBeenCalledOnce();
});
it('keeps bootstrap optout across session rotation and later hook reload', async () => {
await hookSystem.initialize({ skipFileWatching: true });
await fire('fireSessionEndEvent', [SessionEndReason.Clear]);
vi.mocked(mockConfig.getSessionId).mockReturnValue('new-session');
await hookSystem.reload();
expect(FileChangedWatcher).not.toHaveBeenCalled();
});
it('does not arm a session ended while its outgoing watcher is closing', async () => {
await start();
let finish!: () => void;
vi.mocked(watcher().close).mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
vi.mocked(mockConfig.getSessionId).mockReturnValue('new-session');
const reloaded = hookSystem.reload();
await vi.waitFor(() => expect(watcher().close).toHaveBeenCalledOnce());
const ended = fire('fireSessionEndEvent', [SessionEndReason.Clear]);
finish();
await Promise.all([reloaded, ended]);
expect(FileChangedWatcher).toHaveBeenCalledOnce();
});
it('does not rearm a session when its pending SessionStart finishes after SessionEnd', async () => {
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([]);
await hookSystem.initialize();
let finish!: (result: AggregatedHookResult) => void;
vi.mocked(mockHookEventHandler.fireSessionStartEvent).mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const started = hookSystem.fireSessionStartEvent(
SessionStartSource.Startup,
'model',
);
await vi.waitFor(() =>
expect(
mockHookEventHandler.fireSessionStartEvent,
).toHaveBeenCalledOnce(),
);
await fire('fireSessionEndEvent', [SessionEndReason.Clear]);
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([
{ ...registryEntry(HookEventName.FileChanged), matcher: 'notes.md' },
]);
finish({ ...aggregated(), allOutputs: [watchOutput(['/ended-memory'])] });
await started;
await hookSystem.reload();
expect(FileChangedWatcher).not.toHaveBeenCalled();
});
it('does not resume ended watching after an aborted SessionStart', async () => {
await start();
await fire('fireSessionEndEvent', [SessionEndReason.Clear]);
const controller = new AbortController();
controller.abort();
await fire('fireSessionStartEvent', [
SessionStartSource.Clear,
'model',
undefined,
undefined,
controller.signal,
]);
await hookSystem.reload();
expect(FileChangedWatcher).toHaveBeenCalledOnce();
});
it('ignores an outgoing SessionStart that finishes after the replacement starts', async () => {
await start();
let finish!: (result: AggregatedHookResult) => void;
vi.mocked(mockHookEventHandler.fireSessionStartEvent).mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const outgoing = hookSystem.fireSessionStartEvent(
SessionStartSource.Clear,
'model',
);
await vi.waitFor(() =>
expect(
mockHookEventHandler.fireSessionStartEvent,
).toHaveBeenCalledTimes(2),
);
vi.mocked(mockConfig.getSessionId).mockReturnValue('new-session');
await start();
const replacement = watcher();
finish({
...aggregated(),
allOutputs: [watchOutput(['/outgoing-memory'])],
});
await outgoing;
expect(FileChangedWatcher).toHaveBeenCalledTimes(2);
expect(replacement.close).not.toHaveBeenCalled();
expect(replacement.setPaths).toHaveBeenCalledOnce();
});
it('does not close the replacement watcher when an outgoing SessionEnd completes late', async () => {
await start();
const oldWatcher = watcher();
let finish!: (value: AggregatedHookResult) => void;
vi.mocked(mockHookEventHandler.fireSessionEndEvent).mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const ended = hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
vi.mocked(mockConfig.getSessionId).mockReturnValue('new-session');
await start();
const replacement = watcher();
finish(aggregated());
await ended;
expect(oldWatcher.close).toHaveBeenCalledOnce();
expect(replacement.close).not.toHaveBeenCalled();
});
it('awaits watcher cleanup already started by SessionEnd when closed again', async () => {
await start();
let finish!: () => void;
vi.mocked(watcher().close).mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
vi.mocked(mockHookEventHandler.fireSessionEndEvent).mockResolvedValue(
aggregated(),
);
const ended = hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
let closed = false;
const shutdown = hookSystem.close().then(() => {
closed = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closed).toBe(false);
finish();
await Promise.all([ended, shutdown]);
expect(closed).toBe(true);
});
});
describe('constructor', () => {
it('should create instance with all dependencies', () => {
expect(HookRegistry).toHaveBeenCalledWith(mockConfig);
+220 -12
View File
@@ -5,6 +5,9 @@
*/
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { statSync } from 'node:fs';
import { splitListEntries } from './hook-matcher.js';
import type { Config } from '../config/config.js';
import { HookRegistry } from './hookRegistry.js';
import { HookRunner } from './hookRunner.js';
@@ -20,8 +23,9 @@ import type {
ContextUsageData,
DefaultHookOutput,
HookPhase,
FileChangeEvent,
} from './types.js';
import { createHookOutput, PermissionMode } from './types.js';
import { createHookOutput, PermissionMode, HookEventName } from './types.js';
import type {
SessionStartSource,
SessionEndReason,
@@ -32,7 +36,6 @@ import type {
NotificationType,
PermissionDeniedReason,
PermissionSuggestion,
HookEventName,
FunctionHookCallback,
CommandHookConfig,
HttpHookConfig,
@@ -48,6 +51,10 @@ import type {
import { SessionHooksManager } from './sessionHooksManager.js';
import { resolveHookExecutionOwner } from './hook-execution-context.js';
import type { AsyncHookRegistry } from './asyncHookRegistry.js';
import { FileChangedWatcher } from './file-changed-watcher.js';
import { runWithHookExecutionOwner } from './hook-execution-context.js';
import { runWithInvocationContext } from '../utils/invocation-context.js';
import { promptIdContext } from '../utils/promptIdContext.js';
// Re-export MessagesProvider for external use
export type { MessagesProvider } from './types.js';
@@ -68,6 +75,13 @@ export class HookSystem {
private readonly sessionHooksManager: SessionHooksManager;
/** Optional provider for automatically fetching conversation history */
private messagesProvider?: MessagesProvider;
private fileWatcher?: FileChangedWatcher;
private dynamicWatchPaths: string[] = [];
private fileWatchingAllowed = false;
private fileWatchingSessionId?: string;
private fileWatchingEndedSession?: { sessionId: string };
private fileWatcherClosing: Promise<void> = Promise.resolve();
private closed = false;
constructor(
private readonly config: Config,
@@ -81,7 +95,10 @@ export class HookSystem {
this.hookRunner = new HookRunner(allowedHttpUrls, config); // Pass config for prompt hooks
this.hookAggregator = new HookAggregator();
this.hookPlanner = new HookPlanner(this.hookRegistry);
this.sessionHooksManager = new SessionHooksManager();
this.sessionHooksManager = new SessionHooksManager((sessionId) => {
if (sessionId === this.config.getSessionId())
this.refreshFileWatchingInBackground();
});
this.hookEventHandler = new HookEventHandler(
config,
this.hookPlanner,
@@ -97,15 +114,19 @@ export class HookSystem {
/**
* Initialize the hook system
*/
async initialize(): Promise<void> {
async initialize(options?: { skipFileWatching?: boolean }): Promise<void> {
if (this.managedDispatcher) return;
await this.hookRegistry.initialize();
this.fileWatchingAllowed = !options?.skipFileWatching;
this.fileWatchingSessionId = this.config.getSessionId();
await this.refreshFileWatching();
debugLogger.debug('Hook system initialized successfully');
}
async reload(): Promise<void> {
if (this.managedDispatcher) return;
await this.hookRegistry.reloadConfiguredHooks();
await this.refreshFileWatching();
debugLogger.debug('Hook system reloaded successfully');
}
@@ -144,6 +165,162 @@ export class HookSystem {
*/
setHookEnabled(hookName: string, enabled: boolean): void {
this.hookRegistry.setHookEnabled(hookName, enabled);
this.refreshFileWatchingInBackground();
}
async close(): Promise<void> {
this.closed = true;
await this.stopFileWatching();
}
private stopFileWatching(): Promise<void> {
this.fileWatchingSessionId = undefined;
this.dynamicWatchPaths = [];
const watcher = this.fileWatcher;
this.fileWatcher = undefined;
if (watcher) {
this.fileWatcherClosing = Promise.all([
this.fileWatcherClosing,
watcher.close(),
]).then(() => undefined);
}
return this.fileWatcherClosing;
}
async refreshFileWatchingForWorkingDirectory(): Promise<void> {
await this.refreshFileWatching();
}
private refreshFileWatchingInBackground(): void {
void this.refreshFileWatching().catch((error: unknown) => {
debugLogger.warn(`Failed to update FileChanged watch paths: ${error}`);
});
}
private async refreshFileWatching(
result?: AggregatedHookResult,
eventName?: HookEventName,
): Promise<void> {
const sessionId = this.config.getSessionId();
if (
!this.fileWatchingAllowed ||
this.closed ||
this.fileWatchingEndedSession?.sessionId === sessionId
)
return;
if (
this.managedDispatcher ||
this.config.getSessionExecutionEngine?.() === 'managed' ||
this.config.getExecutionEnvironment?.() ||
this.config.getShellExecutionSandbox?.()
) {
return;
}
if (this.fileWatchingSessionId !== sessionId) {
await this.stopFileWatching();
if (
this.closed ||
sessionId !== this.config.getSessionId() ||
this.fileWatchingEndedSession?.sessionId === sessionId
)
return;
this.fileWatchingSessionId = sessionId;
}
for (const output of result?.allOutputs ?? []) {
const specific = output.hookSpecificOutput;
if (!specific || specific['hookEventName'] !== eventName) continue;
const paths = specific['watchPaths'];
if (paths === undefined) continue;
if (
!Array.isArray(paths) ||
!paths.every(
(value: unknown): value is string =>
typeof value === 'string' && path.isAbsolute(value),
)
) {
debugLogger.warn('FileChanged watchPaths must be absolute paths');
continue;
}
this.dynamicWatchPaths = paths;
}
const entries = this.hookRegistry
.getHooksForEvent(HookEventName.FileChanged)
.filter(
(entry) =>
entry.agentScope === undefined && (entry.isSourceTrusted?.() ?? true),
);
const sessionEntries = this.sessionHooksManager
.getHooksForEvent(this.config.getSessionId(), HookEventName.FileChanged)
.filter((entry) => !entry.trustGated || this.config.isTrustedFolder());
const staticFiles = [...entries, ...sessionEntries].flatMap((entry) =>
(/^[\^(]/.test((entry.matcher ?? '').trimStart())
? []
: splitListEntries(entry.matcher ?? '')
)
.map((filename) => filename.trim())
.filter(
(filename) =>
filename !== '' &&
!/[\\^$|?*+()[\]{}]/.test(filename) &&
path.basename(filename) === filename &&
filename !== '.' &&
filename !== '..',
)
.map((filename) => path.resolve(this.config.getWorkingDir(), filename))
.filter((filename) => {
try {
return statSync(filename).isFile();
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
}),
);
const paths =
this.config.getDisableAllHooks?.() ||
entries.length + sessionEntries.length === 0
? []
: [...this.dynamicWatchPaths, ...staticFiles];
if (!this.fileWatcher && paths.length > 0) {
const owner = Object.freeze({
runtimeId: this.runtimeId,
sessionId: this.config.getSessionId(),
agentId: null,
});
this.fileWatcher = new FileChangedWatcher((filePath, event, signal) =>
runWithInvocationContext(undefined, () =>
promptIdContext.exit(() =>
runWithHookExecutionOwner(owner, async () => {
if (owner.sessionId !== this.config.getSessionId()) return;
await this.fireFileChangedEvent(filePath, event, signal);
}),
),
),
);
}
const dynamicPaths = new Set(
this.dynamicWatchPaths.map((file) => path.resolve(file)),
);
await this.fileWatcher?.setPaths(
paths,
staticFiles.filter((file) => !dynamicPaths.has(file)),
);
}
async fireFileChangedEvent(
filePath: string,
event: FileChangeEvent,
signal?: AbortSignal,
): Promise<AggregatedHookResult> {
const sessionId = this.config.getSessionId();
const result = await this.hookEventHandler.fireFileChangedEvent(
filePath,
event,
signal,
);
if (!signal?.aborted && sessionId === this.config.getSessionId()) {
await this.refreshFileWatching(result, HookEventName.FileChanged);
}
return result;
}
/**
@@ -290,6 +467,21 @@ export class HookSystem {
agentType?: AgentType,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> {
const sessionId = this.config.getSessionId();
const endedSession = this.fileWatchingEndedSession;
if (
this.fileWatchingSessionId !== undefined &&
this.fileWatchingSessionId !== sessionId
) {
await this.stopFileWatching();
}
await this.fileWatcherClosing;
if (
this.closed ||
sessionId !== this.config.getSessionId() ||
this.fileWatchingEndedSession !== endedSession
)
return undefined;
const result = await this.hookEventHandler.fireSessionStartEvent(
source,
model,
@@ -297,6 +489,17 @@ export class HookSystem {
agentType,
signal,
);
if (
!signal?.aborted &&
!this.closed &&
sessionId === this.config.getSessionId() &&
this.fileWatchingEndedSession === endedSession
) {
this.fileWatchingEndedSession = undefined;
this.fileWatchingAllowed = true;
this.fileWatchingSessionId = sessionId;
await this.refreshFileWatching(result, HookEventName.SessionStart);
}
return result.finalOutput
? createHookOutput('SessionStart', result.finalOutput)
: undefined;
@@ -306,10 +509,11 @@ export class HookSystem {
reason: SessionEndReason,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSessionEndEvent(
reason,
signal,
);
this.fileWatchingEndedSession = { sessionId: this.config.getSessionId() };
const ended = this.hookEventHandler.fireSessionEndEvent(reason, signal);
const stopped = this.stopFileWatching();
const result = await ended;
await stopped;
return result.finalOutput
? createHookOutput('SessionEnd', result.finalOutput)
: undefined;
@@ -665,7 +869,7 @@ export class HookSystem {
skillRoot?: string;
},
): string {
return this.sessionHooksManager.addFunctionHook(
const id = this.sessionHooksManager.addFunctionHook(
sessionId,
event,
matcher,
@@ -673,6 +877,7 @@ export class HookSystem {
errorMessage,
options,
);
return id;
}
/**
@@ -695,13 +900,14 @@ export class HookSystem {
trustGated?: boolean;
},
): string {
return this.sessionHooksManager.addSessionHook(
const id = this.sessionHooksManager.addSessionHook(
sessionId,
event,
matcher,
hook,
options,
);
return id;
}
/**
@@ -716,11 +922,12 @@ export class HookSystem {
event: HookEventName,
hookId: string,
): boolean {
return this.sessionHooksManager.removeFunctionHook(
const removed = this.sessionHooksManager.removeFunctionHook(
sessionId,
event,
hookId,
);
return removed;
}
/**
@@ -730,7 +937,8 @@ export class HookSystem {
* @returns True if hook was found and removed
*/
removeSessionHook(sessionId: string, hookId: string): boolean {
return this.sessionHooksManager.removeHook(sessionId, hookId);
const removed = this.sessionHooksManager.removeHook(sessionId, hookId);
return removed;
}
/**
@@ -95,6 +95,7 @@ const calls: Record<
'project',
'session_start',
),
FileChanged: (h) => h.fireFileChangedEvent('/workspace/notes.md', 'change'),
};
function fixture() {
@@ -66,6 +66,8 @@ interface SessionHooksStorage {
export class SessionHooksManager {
private readonly sessions: Map<string, SessionHooksStorage> = new Map();
constructor(private readonly onHooksChanged?: (sessionId: string) => void) {}
/**
* Get or create session storage
*/
@@ -130,6 +132,7 @@ export class SessionHooksManager {
const eventHooks = storage.hooks.get(event) || [];
eventHooks.push(entry);
storage.hooks.set(event, eventHooks);
this.onHooksChanged?.(sessionId);
debugLogger.debug(
`Added function hook ${hookId} for session ${sessionId} on event ${event}`,
@@ -173,6 +176,7 @@ export class SessionHooksManager {
const eventHooks = storage.hooks.get(event) || [];
eventHooks.push(entry);
storage.hooks.set(event, eventHooks);
this.onHooksChanged?.(sessionId);
debugLogger.debug(
`Added session hook ${hookId} for session ${sessionId} on event ${event}`,
@@ -209,6 +213,7 @@ export class SessionHooksManager {
}
eventHooks.splice(index, 1);
this.onHooksChanged?.(sessionId);
debugLogger.debug(
`Removed hook ${hookId} from session ${sessionId} on event ${event}`,
);
@@ -232,6 +237,7 @@ export class SessionHooksManager {
const index = eventHooks.findIndex((entry) => entry.hookId === hookId);
if (index !== -1) {
eventHooks.splice(index, 1);
this.onHooksChanged?.(sessionId);
debugLogger.debug(
`Removed hook ${hookId} from session ${sessionId} on event ${event}`,
);
@@ -325,6 +331,7 @@ export class SessionHooksManager {
*/
clearSessionHooks(sessionId: string): void {
this.sessions.delete(sessionId);
this.onHooksChanged?.(sessionId);
debugLogger.debug(`Cleared all hooks for session ${sessionId}`);
}
+9
View File
@@ -66,6 +66,7 @@ export enum HookEventName {
TodoCompleted = 'TodoCompleted',
// InstructionsLoaded - When an instruction or context file is loaded
InstructionsLoaded = 'InstructionsLoaded',
FileChanged = 'FileChanged',
}
/**
@@ -292,6 +293,13 @@ export interface InstructionsLoadedInput extends HookInput {
parent_file_path?: string;
}
export type FileChangeEvent = 'add' | 'change' | 'unlink';
export interface FileChangedInput extends HookInput {
file_path: string;
event: FileChangeEvent;
}
/**
* Base hook output - common fields for all events
*/
@@ -1107,6 +1115,7 @@ export interface SessionStartOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'SessionStart';
additionalContext?: string;
watchPaths?: string[];
};
}
@@ -84,6 +84,7 @@ import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.r
import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.renewalDelayMillis;
import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.requireOpen;
import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.safeStage;
import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.sameReceipt;
import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.sshNoStartReceipt;
import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.unavailable;
import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.unwrap;
@@ -1981,44 +1982,6 @@ public final class RuntimeBrokerService implements AutoCloseable, com.alibaba.qw
})));
}
private static boolean sameReceipt(Map<String, Object> expected,
Map<String, Object> actual) {
if (actual == null || !actual.keySet().equals(Set.of("executionCallId", "manifest",
"deliveryStatus", "historyRevision"))
|| !java.util.Objects.equals(expected.get("executionCallId"), actual.get("executionCallId"))
|| !java.util.Objects.equals(expected.get("deliveryStatus"), actual.get("deliveryStatus"))) {
return false;
}
Object leftRevision = expected.get("historyRevision");
Object rightRevision = actual.get("historyRevision");
if (leftRevision == null ? rightRevision != null
: !(rightRevision instanceof Number number)
|| ((Number) leftRevision).longValue() != number.longValue()) {
return false;
}
Object left = expected.get("manifest");
Object right = actual.get("manifest");
if (left == null || right == null) {
return left == right;
}
if (!(left instanceof Map<?, ?> saved) || !(right instanceof Map<?, ?> received)
|| !saved.keySet().equals(received.keySet())) {
return false;
}
for (String field : List.of("resourceId", "kind", "digest")) {
if (!java.util.Objects.equals(saved.get(field), received.get(field))) {
return false;
}
}
for (String field : List.of("schemaVersion", "byteLength")) {
if (!(saved.get(field) instanceof Number a) || !(received.get(field) instanceof Number b)
|| a.longValue() != b.longValue()) {
return false;
}
}
return true;
}
public CompletionStage<ToolExecutionRecord> cancelExecution(
String harnessSessionId, String runtimeSessionId,
String executionCallId) {
@@ -12,6 +12,7 @@ import com.alibaba.qwen.code.runtimebroker.protocol.ProviderRuntimeProtocol;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -209,6 +210,44 @@ final class RuntimeBrokerSupport {
}
}
static boolean sameReceipt(Map<String, Object> expected,
Map<String, Object> actual) {
if (actual == null || !actual.keySet().equals(Set.of("executionCallId", "manifest",
"deliveryStatus", "historyRevision"))
|| !java.util.Objects.equals(expected.get("executionCallId"), actual.get("executionCallId"))
|| !java.util.Objects.equals(expected.get("deliveryStatus"), actual.get("deliveryStatus"))) {
return false;
}
Object leftRevision = expected.get("historyRevision");
Object rightRevision = actual.get("historyRevision");
if (leftRevision == null ? rightRevision != null
: !(rightRevision instanceof Number number)
|| ((Number) leftRevision).longValue() != number.longValue()) {
return false;
}
Object left = expected.get("manifest");
Object right = actual.get("manifest");
if (left == null || right == null) {
return left == right;
}
if (!(left instanceof Map<?, ?> saved) || !(right instanceof Map<?, ?> received)
|| !saved.keySet().equals(received.keySet())) {
return false;
}
for (String field : List.of("resourceId", "kind", "digest")) {
if (!java.util.Objects.equals(saved.get(field), received.get(field))) {
return false;
}
}
for (String field : List.of("schemaVersion", "byteLength")) {
if (!(saved.get(field) instanceof Number a) || !(received.get(field) instanceof Number b)
|| a.longValue() != b.longValue()) {
return false;
}
}
return true;
}
static Map<String, Object> sshNoStartReceipt(RuntimeBindingRecord binding, ToolExecutionRecord execution) {
if (!com.alibaba.qwen.code.runtimebroker.protocol.RuntimeProviderKinds.SSH_PROCESS.equals(binding.getRequest().getProvisionerKind())
|| !execution.isSettled() || !"not_started".equals(execution.getExecutionStatus())
@@ -1168,12 +1168,16 @@ public class EventTransportMultiNodeGate {
public Node start(boolean transport) throws Exception {
Files.createDirectories(root);
startHarness();
startHarness(System.nanoTime() + Duration.ofSeconds(90).toNanos());
startSpring(transport);
return this;
}
public void startHarness() throws Exception {
startHarness(System.nanoTime() + Duration.ofSeconds(90).toNanos());
}
public void startHarness(long deadline) throws Exception {
Path home = Files.createDirectories(root.resolve("home"));
Path config = Files.createDirectories(home.resolve(".qwen"));
Path workspace = Files.createDirectories(root.resolve("workspace"));
@@ -1226,7 +1230,8 @@ public class EventTransportMultiNodeGate {
// The process is gone; the log holds what it said.
}
});
await().atMost(Duration.ofSeconds(60)).pollInterval(Duration.ofMillis(100))
await().atMost(remainingBootTime(deadline, Duration.ofSeconds(60)))
.pollDelay(Duration.ZERO).pollInterval(Duration.ofMillis(100))
.until(() -> {
assertThat(harness.isAlive()).as("%s Harness exited:\n%s", name,
harnessOutput()).isTrue();
@@ -1237,7 +1242,8 @@ public class EventTransportMultiNodeGate {
harnessPort = Integer.parseInt(match.group(1));
return true;
});
await().atMost(Duration.ofSeconds(30)).pollInterval(Duration.ofMillis(100))
await().atMost(remainingBootTime(deadline, Duration.ofSeconds(30)))
.pollDelay(Duration.ZERO).pollInterval(Duration.ofMillis(100))
.until(this::harnessReady);
JsonNode capabilities = JSON.readTree(readCapabilities((HttpURLConnection)
URI.create("http://127.0.0.1:" + harnessPort + "/capabilities").toURL()
@@ -1279,12 +1285,37 @@ public class EventTransportMultiNodeGate {
}
public void startSpring(boolean transport) throws Exception {
launch(transport);
// Only the first boot of the class migrates the schema.
Duration boot = Duration.ofSeconds(schemaReady ? 90 : 180);
long deadline = System.nanoTime() + boot.toNanos();
boolean firstBoot = springPort == 0;
Path ready = root.resolve("ready.json");
await().atMost(boot).pollInterval(Duration.ofMillis(100)).until(() ->
Files.exists(ready) || !spring.isAlive());
Path log = root.resolve("spring.log");
for (int attempt = 0; ; attempt++) {
long offset = Files.exists(log) ? Files.size(log) : 0;
launch(transport);
await().atMost(remainingBootTime(deadline, boot))
.pollDelay(Duration.ZERO).pollInterval(Duration.ofMillis(100)).until(() ->
Files.exists(ready) || !spring.isAlive());
if (spring.isAlive() || Files.exists(ready) || !firstBoot
|| attempt >= 2 || spring.exitValue() == 0) {
break;
}
byte[] bytes = Files.readAllBytes(log);
int from = (int) Math.min(offset, bytes.length);
String failure = new String(bytes, from, bytes.length - from, StandardCharsets.UTF_8);
if (!failure.contains("Port " + springPort + " was already in use.")
|| System.nanoTime() >= deadline) {
break;
}
springPort = 0;
// An early consumer may already have attached the old store URL.
kill(harness);
synchronized (harnessOutput) {
harnessOutput.setLength(0);
}
startHarness(deadline);
}
long observed = System.currentTimeMillis();
assertThat(spring.isAlive()).as("%s failed to start:\n%s", name,
tail(root.resolve("spring.log"))).isTrue();
@@ -1408,6 +1439,11 @@ public class EventTransportMultiNodeGate {
}
}
private static Duration remainingBootTime(long deadline, Duration limit) {
return Duration.ofNanos(Math.max(1,
Math.min(limit.toNanos(), deadline - System.nanoTime())));
}
private static int freePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) {
return socket.getLocalPort();
@@ -71,6 +71,8 @@ class HostedAutomationFaultGatesIT {
private static final String DIGEST = "sha256:" + "a".repeat(64);
// A public task event cursor: base64url of its scope key and sequence.
private static final Pattern TASK_CURSOR = Pattern.compile("tev2:[^:]+:([0-9]+)");
private static final Pattern HARNESS_LISTENING = Pattern.compile(
"qwen serve listening on http://127\\.0\\.0\\.1:(\\d+)");
private static final String OPERATIONS = "/automations/operations";
// The Harness store client's bounded retry of one commit (http-managed-session-store.ts).
private static final int STORE_ATTEMPTS = 3;
@@ -620,9 +622,13 @@ class HostedAutomationFaultGatesIT {
+ "' AND NEW.definition_revision <> OLD.definition_revision", "NEW.definition_revision");
Map<String, Object> update = Map.of("prompt", "FG11B_PROJECTION_TWO reply with FG11_DONE.");
String path = "/v1/agent-automations/" + id;
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
assertUnknown(send("POST", path, update, "projection-update"));
assertThat(refusedDetails("projection")).as("the store client's bounded retry")
.hasSizeGreaterThanOrEqualTo(STORE_ATTEMPTS).containsOnly("2");
long remaining = deadline - System.nanoTime();
assertThat(remaining).as("the original request window remains").isPositive();
waiting().pollDelay(Duration.ZERO).atMost(Duration.ofNanos(remaining)).untilAsserted(() ->
assertThat(refusedDetails("projection")).as("the store client's bounded retry")
.hasSizeGreaterThanOrEqualTo(STORE_ATTEMPTS).containsOnly("2"));
assertThat(commands(session, "defineSchedule")).as("the rolled-back revision").hasSize(1);
assertThat(recordRevision(session, "schedule", id)).isEqualTo(1);
assertThat(mirrorRevision(id)).isEqualTo(1);
@@ -2495,7 +2501,7 @@ class HostedAutomationFaultGatesIT {
return filter;
}
// A cold Harness: a fresh HOME and QWEN_HOME each start, on the same port.
// A cold Harness: fresh HOME and QWEN_HOME; the relay follows its bound port.
private void startHarness() throws Exception {
Path cli = harnessCli;
Path home = Files.createDirectory(temporary.resolve("home-" + UUID.randomUUID()));
@@ -2506,9 +2512,10 @@ class HostedAutomationFaultGatesIT {
"telemetry", Map.of("enabled", false), "modelProviders", Map.of("openai", List.of(Map.of(
"id", "fg11-fixture", "envKey", "OPENAI_API_KEY", "baseUrl", modelUrl))))));
Path log = temporary.resolve("harness.log");
int logOffset = Files.exists(log) ? Files.readString(log).length() : 0;
ProcessBuilder builder = new ProcessBuilder(node, cli.toString(),
"serve", "--profile", "hosted-harness", "--http-bridge", "--hostname", "127.0.0.1", "--port",
Integer.toString(harnessPort), "--require-auth", "--no-web", "--workspace", decoy.toString(),
harness == null ? "0" : Integer.toString(harnessPort), "--require-auth", "--no-web", "--workspace", decoy.toString(),
"--managed-runtime-broker-url", "http://127.0.0.1:" + brokerPort,
"--managed-runtime-broker-token", TOKEN)
.directory(decoy.toFile()).redirectErrorStream(true)
@@ -2533,6 +2540,10 @@ class HostedAutomationFaultGatesIT {
if (!harness.isAlive()) {
throw new AssertionError("Hosted Harness exited: " + Files.readString(log));
}
var listening = HARNESS_LISTENING.matcher(Files.readString(log).substring(logOffset));
assertThat(listening.find()).as("Hosted Harness reported its bound port").isTrue();
harnessPort = Integer.parseInt(listening.group(1));
harnessRelay.backend(URI.create("http://127.0.0.1:" + harnessPort));
assertThat(http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + harnessPort
+ "/capabilities")).timeout(Duration.ofSeconds(2)).header("Authorization", "Bearer " + TOKEN)
.build(), HttpResponse.BodyHandlers.discarding()).statusCode()).isEqualTo(200);
@@ -1325,12 +1325,12 @@ class HostedChannelFaultGatesIT {
assertThat(support.inputAcceptedCount(tenant, sessionId))
.as("the replay never commits a second input")
.isEqualTo(1);
assertThat(modelCallsFor(fault))
.as("the replay never re-runs the turn")
.isEqualTo(1);
JsonNode replayed = support.drive(caseDir, "pull", base);
assertThat(replayed.path("sends")).hasSize(1);
HostedChannelGateSupport.assertSendsTo(replayed);
assertThat(modelCallsFor(fault))
.as("the replay never re-runs the turn")
.isEqualTo(1);
assertThat(((Number) support.routeProjection(tenant,
sessionId).get("revision")).intValue())
.isEqualTo(1);
@@ -1,6 +1,7 @@
package com.alibaba.qwen.code.managedagent;
import com.alibaba.qwen.code.managedagent.contracts.session.AgentStateStore;
import com.alibaba.qwen.code.managedagent.persistence.WorkspaceTestStorage;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection;
import com.fasterxml.jackson.databind.JsonNode;
@@ -407,6 +408,7 @@ class HostedProcessCrashIT {
}
startSpring();
if (List.of("Linux", "Mac OS X").contains(System.getProperty("os.name"))) {
WorkspaceTestStorage.prepareNativeRoot(root.resolve("workspace"));
var storageRegistrationProperties = new com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties();
storageRegistrationProperties.getRuntimeBroker().setVerifiedWorkspaceRecoveryEnabled(true);
storageRegistrationProperties.getRuntimeBroker().setWorkspaceMounts(List.of(
@@ -4,6 +4,7 @@ import com.alibaba.qwen.code.daemon.HostedHarnessClient;
import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor;
import com.alibaba.qwen.code.managedagent.config.runtime.EmbeddedRuntimeBroker;
import com.alibaba.qwen.code.managedagent.contracts.harness.HarnessConnector;
import com.alibaba.qwen.code.managedagent.persistence.WorkspaceTestStorage;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -1534,7 +1535,7 @@ class HostedPublicWorkspaceIT {
assertThat(modelRequests).hasSize(approvals ? 16 : 30);
}
private void startSpring(Path cli, List<Path> roots, int harnessPort, int brokerPort) {
private void startSpring(Path cli, List<Path> roots, int harnessPort, int brokerPort) throws IOException {
boolean mysql = System.getProperty("mysql.url") != null;
List<String> arguments = new ArrayList<>(List.of("--server.address=127.0.0.1", "--server.port=" + port,
"--spring.datasource.url=" + System.getProperty("mysql.url",
@@ -1611,6 +1612,7 @@ class HostedPublicWorkspaceIT {
if (!restarted && (definitionApproval || "Linux".equals(System.getProperty("os.name")))) {
var guard = spring.getBean(com.alibaba.qwen.code.managedagent.persistence.WorkspaceStorageGuard.class);
for (int index = 0; index < roots.size(); index++) {
WorkspaceTestStorage.prepareNativeRoot(roots.get(index));
guard.register(tenant, "storage-" + index, UUID.randomUUID().toString());
}
}
@@ -1,5 +1,6 @@
package com.alibaba.qwen.code.managedagent;
import com.alibaba.qwen.code.managedagent.persistence.WorkspaceTestStorage;
import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.alibaba.qwen.code.runtimebroker.adapter.security.AesGcmSecretProtector;
@@ -122,6 +123,7 @@ class HostedShellLifecycleRecoveryIT {
private void run() throws Exception {
startHarness();
startSpring();
WorkspaceTestStorage.prepareNativeRoot(root.resolve("workspace"));
var storageRegistrationProperties = new com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties();
storageRegistrationProperties.getRuntimeBroker().setVerifiedWorkspaceRecoveryEnabled(true);
storageRegistrationProperties.getRuntimeBroker().setWorkspaceMounts(List.of(
@@ -244,6 +246,7 @@ class HostedShellLifecycleRecoveryIT {
assertThat(((Number) writerIdentity().get("writer_generation")).longValue())
.isEqualTo(((Number) writer.get("writer_generation")).longValue());
}
assertThat(writerIdentity()).containsEntry("writer_id", null).containsEntry("lease_token_hash", null);
assertThat(jdbc.queryForObject("SELECT state FROM ma_session_head WHERE tenant_id = ? AND session_id = ?",
String.class, tenant, session)).isEqualTo("DELETED");
assertThat(jdbc.queryForObject("SELECT lifecycle_protocol_version FROM ma_operation WHERE operation_id = ?",
@@ -2,6 +2,7 @@ package com.alibaba.qwen.code.managedagent;
import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor;
import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection;
import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore;
import com.alibaba.qwen.code.managedagent.persistence.WorkspaceTestStorage;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -812,7 +813,7 @@ class HostedTeamLeadMySqlIT {
}
private void startSpring(Path cli, String node, Path decoy, Path workspace, int harnessPort, int brokerPort,
String storageOperation) {
String storageOperation) throws IOException {
List<String> arguments = new ArrayList<>(List.of("--server.address=127.0.0.1", "--server.port=" + port,
"--spring.datasource.url=" + System.getProperty("mysql.url"),
"--spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
@@ -857,6 +858,7 @@ class HostedTeamLeadMySqlIT {
.run(arguments.toArray(String[]::new));
jdbc = spring.getBean(JdbcTemplate.class);
if ("Linux".equals(System.getProperty("os.name"))) {
WorkspaceTestStorage.prepareNativeRoot(workspace);
spring.getBean(com.alibaba.qwen.code.managedagent.persistence.WorkspaceStorageGuard.class)
.register(tenant, "storage-0", storageOperation);
}
@@ -118,11 +118,14 @@ final class HostedWorkflowProbe implements AutoCloseable {
String resourceId = record.required("inputRef")
.required("resourceId").asText();
var envelopeRows = jdbc.queryForList(
"SELECT CONVERT(inline_bytes USING utf8mb4) AS body FROM"
"SELECT resource_key,"
+ " CONVERT(inline_bytes USING utf8mb4) AS body FROM"
+ " ma_resource WHERE tenant_id = ? AND session_id"
+ " = ? AND resource_id = ?", tenant, sessionId,
resourceId);
assertThat(envelopeRows).as("the launch envelope").hasSize(1);
String resourceKey = envelopeRows.getFirst().get("resource_key")
.toString();
ObjectNode envelope = (ObjectNode) JSON.readTree(
envelopeRows.getFirst().get("body").toString());
String script = envelope.required("script").asText();
@@ -157,14 +160,16 @@ final class HostedWorkflowProbe implements AutoCloseable {
MessageDigest.getInstance("SHA-256").digest(bytes));
int updated = jdbc.update(
"UPDATE ma_resource SET inline_bytes = ?, byte_length = ?,"
+ " sha256 = ? WHERE tenant_id = ? AND session_id"
+ " = ? AND resource_id = ?", bytes, bytes.length,
shaAfter, tenant, sessionId, resourceId);
+ " sha256 = ? WHERE resource_key = ? AND tenant_id = ?"
+ " AND session_id = ? AND resource_id = ?", bytes,
bytes.length, shaAfter, resourceKey, tenant, sessionId,
resourceId);
assertThat(updated).as("the envelope row updated").isEqualTo(1);
var reread = jdbc.queryForList(
"SELECT sha256, byte_length FROM ma_resource WHERE"
+ " tenant_id = ? AND session_id = ? AND"
+ " resource_id = ?", tenant, sessionId, resourceId);
+ " resource_key = ? AND tenant_id = ? AND session_id"
+ " = ? AND resource_id = ?", resourceKey, tenant,
sessionId, resourceId);
assertThat(reread.getFirst().get("sha256").toString())
.as("the tamper landed").isEqualTo(shaAfter);
return Map.of("resourceId", resourceId, "childRunId", recordId,
@@ -804,10 +804,9 @@ class HostedWorkflowRelayFaultGatesIT {
return attempts == null ? 0 : attempts;
}
/** The lead's record settled and its delivery consumed, and the
* relay classified its ledger row — the record's consumed revision
* lands before the row's classification: the whole walk ran, through
* the wake Turn's read. */
/** Wait for the lead's consuming revision and the relay ledger's
* matching terminal state. Classification can precede consumption;
* the consuming revision corrects that mirror in its own commit. */
private void awaitConsumed(String lead) {
waiting().atMost(Duration.ofSeconds(150))
.pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> {
@@ -818,8 +817,8 @@ class HostedWorkflowRelayFaultGatesIT {
.isEqualTo("settled");
assertThat(record.path("run").path("delivery")
.path("state").asText()).isEqualTo("consumed");
assertThat(childResultRows()).isNotEmpty()
.allMatch(row -> row.endsWith("/consumed"));
assertThat(childResultRows())
.containsExactly("delivered/accepted/consumed");
});
}
@@ -1026,13 +1026,17 @@ class ManagedSessionLifecycleTest {
logged.start();
logger.addAppender(logged);
try {
assertThatThrownBy(() -> subject.renameSession(tenant, null,
"key", sessionId, "renamed"))
.isInstanceOfSatisfying(ManagedAgentException.class, error -> {
assertThat(error.getCode())
.isEqualTo("hosted_harness_unavailable");
assertThat(error.getCause()).isSameAs(failure);
});
// Keep recovery from claiming the row before the inline attempt
// reaches the injected store failure.
new TransactionTemplate(transactionManager).executeWithoutResult(ignored -> {
assertThatThrownBy(() -> subject.renameSession(tenant, null,
"key", sessionId, "renamed"))
.isInstanceOfSatisfying(ManagedAgentException.class, error -> {
assertThat(error.getCode())
.isEqualTo("hosted_harness_unavailable");
assertThat(error.getCause()).isSameAs(failure);
});
});
} finally {
logger.detachAppender(logged);
}
@@ -42,6 +42,7 @@ import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Clock;
@@ -2825,6 +2826,7 @@ class ManagedWorkspaceAdmissionTest {
}
@Test
@Transactional("managedAgentTransactionManager")
void failedAndBlockedWorktreesStillAllowCancellation() throws Exception {
for (String state : List.of("FAILED", "BLOCKED")) {
String tenant = "tenant-" + UUID.randomUUID();
@@ -42,7 +42,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
class ClassSizeRatchetTest {
private static final int LARGE_CLASS_THRESHOLD = 800;
private static final int GLOBAL_MAX_LINES = 4253;
private static final int GLOBAL_MAX_LINES = 4216;
private static final String PIN_RESOURCE = "/class-size-ratchet.properties";
private static final List<String> SOURCE_ROOTS =
List.of("persistence/src/main/java", "runtime-broker/src/main/java");
@@ -4,11 +4,21 @@ import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingReposit
import com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys;
import org.springframework.jdbc.core.JdbcTemplate;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/** Explicit storage model for offline SQL tests; native mount tests use the real guard. */
public final class WorkspaceTestStorage {
private WorkspaceTestStorage() {
}
public static void prepareNativeRoot(Path root) throws IOException {
// A real child mutation makes native birth time distinguishable from mtime.
Path probe = Files.createTempDirectory(root, ".storage-registration-touch-");
Files.delete(probe);
}
public static String key(String tenant, String storage) {
return ManagedSchemaKeys.key("offline-test-physical", tenant, storage);
}
@@ -3661,6 +3661,7 @@ class HarnessCoordinatorTest {
HarnessCoordinator coordinator = new HarnessCoordinator(store, harness,
new HarnessEventProjector(), mock(RuntimeWarmer.class), executor,
Clock.systemUTC(), new ManagedAgentProperties());
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
try {
coordinator.dispatch("tenant", "session", "turn");
if (terminalAtProbe) {
@@ -3673,6 +3674,8 @@ class HarnessCoordinatorTest {
verify(store, timeout(20_000)).markTurnRecoveryBlocked(eq("tenant"), eq("session"),
eq("turn"), anyString(), eq(AgentStateStore.HARNESS_RECOVERY_BLOCKED), anyString());
}
assertThat(closed.await(Math.max(0, deadline - System.nanoTime()),
TimeUnit.NANOSECONDS)).isTrue();
} finally {
coordinator.close();
executor.shutdownNow();
@@ -969,7 +969,7 @@ class Issue13181QueryBudgetTest {
void asynchronousVerificationHeartbeatsAndStatusAvoidJournalBodyReads() {
Fixture fixture = new Fixture();
PublicationFixture publication = publicationFixture(fixture);
byte[] bytes = new byte[] {1, 2, 3, 4};
byte[] bytes = new byte[16];
ToolPublicationObjectStore bucket = new ToolPublicationObjectStore() {
@Override
public void putIfAbsent(String key, byte[] input) {}
@@ -1000,7 +1000,7 @@ class Issue13181QueryBudgetTest {
public void validateConfiguration() {}
};
ToolPublicationDataStore data = new ToolPublicationDataStore(fixture.jdbc, fixture.manager,
publication.store(), publication.sessions(), bucket, Duration.ofSeconds(10), Duration.ofMillis(300),
publication.store(), publication.sessions(), bucket, Duration.ofSeconds(10), Duration.ofSeconds(2),
new ToolPublicationDataStore.VerificationBudget(16 * 1024 * 1024, Duration.ofMinutes(25)));
data.publishSegment(binding.path("sessionKey"), "pub-1", PUBLICATION_TOKEN, "async-heartbeat",
"stdout", 0, bytes, null, true);
@@ -39,6 +39,7 @@ import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -513,8 +514,12 @@ class DurableLocalProcessRuntimeProvisionerTest {
// milliseconds; a 1.2s operation deadline could beat
// it on a loaded runner and leave the lock held.
Duration.ofSeconds(5), Duration.ofMillis(300))) {
assertThrows(java.util.concurrent.ExecutionException.class,
var spawnFailure = assertThrows(java.util.concurrent.ExecutionException.class,
() -> await(service.warm("harness")));
var blockedFailure = assertInstanceOf(RuntimeBrokerException.class,
spawnFailure.getCause());
assertEquals("runtime_broker_recovery_blocked", blockedFailure.getCode());
assertInstanceOf(java.io.IOException.class, blockedFailure.getCause());
var request = provisioner.createRequest(scope, "harness");
var blocked = bindings.findActive(request);
assertEquals(RuntimeBindingRecord.State.RECOVERY_BLOCKED, blocked.getState());
@@ -38,4 +38,4 @@ com.alibaba.qwen.code.managedagent.persistence.WorkspaceStorageGuard=877
com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission=990
com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository=2670
com.alibaba.qwen.code.runtimebroker.persistence.JdbcToolExecutionRepository=1235
com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerService=4253
com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerService=4216
@@ -5689,6 +5689,7 @@ export type DaemonHookEventName =
| 'TodoCreated'
| 'TodoCompleted'
| 'InstructionsLoaded'
| 'FileChanged'
| (string & {});
export type DaemonHookMatcherKind =
@@ -4520,6 +4520,118 @@
"hooks"
]
}
},
"FileChanged": {
"description": "Hooks that execute when a watched file is added, changed, or deleted in a local session.",
"type": "array",
"items": {
"description": "A hook definition with an optional matcher and a list of hook configurations.",
"type": "object",
"properties": {
"matcher": {
"description": "An optional matcher pattern to filter when this hook definition applies.",
"type": "string"
},
"sequential": {
"description": "Whether the hooks should be executed sequentially instead of in parallel.",
"type": "boolean"
},
"hooks": {
"description": "The list of hook configurations to execute.",
"type": "array",
"items": {
"description": "A hook configuration entry that defines a hook to execute.",
"type": "object",
"properties": {
"type": {
"description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.",
"type": "string",
"enum": [
"command",
"http",
"prompt"
]
},
"command": {
"description": "The command to execute when the hook is triggered. Required for \"command\" type.",
"type": "string"
},
"url": {
"description": "The URL to send the POST request to. Required for \"http\" type.",
"type": "string"
},
"prompt": {
"description": "The prompt to send to the model. Required for \"prompt\" type.",
"type": "string"
},
"model": {
"description": "The optional model to use for a \"prompt\" hook.",
"type": "string"
},
"headers": {
"description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"allowedEnvVars": {
"description": "List of environment variables allowed for interpolation in headers and URL.",
"type": "array",
"items": {
"type": "string"
}
},
"name": {
"description": "An optional name for the hook.",
"type": "string"
},
"description": {
"description": "An optional description of what the hook does.",
"type": "string"
},
"timeout": {
"description": "Timeout in seconds for the hook execution. Defaults to 60 for command hooks, 600 for http hooks and 30 for prompt hooks. For command hooks, a value of 1000 or more is read as legacy milliseconds.",
"type": "number"
},
"env": {
"description": "Environment variables to set when executing the hook command.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"async": {
"description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).",
"type": "boolean"
},
"once": {
"description": "Whether to execute the hook only once per session (for \"http\" type).",
"type": "boolean"
},
"statusMessage": {
"description": "A message to display while the hook is executing.",
"type": "string"
},
"shell": {
"description": "The shell to use for command execution.",
"type": "string",
"enum": [
"bash",
"powershell"
]
}
},
"required": [
"type"
]
}
}
},
"required": [
"hooks"
]
}
}
}
},
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const BROWSER_NOTIFICATION_MESSAGES_EN = {};
export const BROWSER_NOTIFICATION_MESSAGES_ZH = {};
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
type BrowserNotificationMessage =
| string
| ((vars?: Record<string, string | number>) => string);
export const BROWSER_NOTIFICATION_MESSAGES_EN: Record<
string,
BrowserNotificationMessage
> = {
'browserNotifications.label': 'Task notifications',
'browserNotifications.description':
'Notify when the current chat or a split-view chat finishes, fails, needs approval or waits for your answer while this page is in the background or unfocused. Completed turns show prompt and reply excerpts; action reminders show the session title and waiting status. Saved for this browser site only; the page must remain open.',
'browserNotifications.prompt': (v) => `Prompt: ${v?.text ?? ''}`,
'browserNotifications.reply': (v) => `Reply: ${v?.text ?? ''}`,
'browserNotifications.completed': 'This turn has completed.',
'browserNotifications.failed':
'This turn failed. Return to view the details.',
'browserNotifications.ended':
'This turn has ended. Return to check the result.',
'browserNotifications.approval':
'This session needs your approval. Return to review the request.',
'browserNotifications.question':
'This session is waiting for your answer. Return to respond.',
'browserNotifications.allow': 'Allow notifications',
'browserNotifications.enable': 'Enable notifications',
'browserNotifications.helpEnabled': 'Enabled for this site.',
'browserNotifications.helpWaiting': 'Notifications are not authorized yet.',
'browserNotifications.helpWhen':
'While this page is in the background or you switch to another tab, get reminders when the current chat needs approval, waits for your answer, finishes or fails. Clicking a notification returns to its chat without approving or answering.',
'browserNotifications.helpSystem':
'If no reminders appear, follow the steps below to check system notifications.',
'browserNotifications.helpMac':
'Open System Settings → Notifications. Select your browser (for Chrome, also check Google Chrome Helper (Alerts) if listed), enable notifications and desktop banners/alerts. Check Focus and whether notifications are allowed during screen sharing.',
'browserNotifications.helpWindows':
'Open Settings → System → Notifications (Windows 10: Notifications & actions). Enable notifications and your browser, including notification banners. Turn off Do not disturb / Focus assist, or allow your browser through its rules.',
'browserNotifications.enabled': 'Enabled.',
'browserNotifications.disabled': 'Disabled.',
'browserNotifications.waiting': 'Waiting for browser permission.',
'browserNotifications.denied':
'Notifications are blocked. Allow them in your browser site settings.',
'browserNotifications.unavailable':
'Notifications are unavailable in this browser or page context.',
'browserNotifications.requesting': 'Waiting for your permission…',
'browserNotifications.error':
'Unable to enable or show notifications. Check your browser and system settings.',
'browserNotifications.temporary':
'This setting is saved for the current page only.',
};
export const BROWSER_NOTIFICATION_MESSAGES_ZH: Record<
string,
BrowserNotificationMessage
> = {
'browserNotifications.label': '任务通知',
'browserNotifications.description':
'页面在后台或窗口失焦时,提醒当前聊天和分屏聊天的回合结束、失败、需要审批或等待回答。回合结束通知包含提问和回复摘录;待处理提醒只显示会话标题和等待状态。仅保存在此浏览器站点;网页需保持打开。',
'browserNotifications.prompt': (v) => `提问:${v?.text ?? ''}`,
'browserNotifications.reply': (v) => `回复:${v?.text ?? ''}`,
'browserNotifications.completed': '本轮已完成。',
'browserNotifications.failed': '本轮执行失败,请返回查看。',
'browserNotifications.ended': '本轮已结束,请返回查看结果。',
'browserNotifications.approval': '此会话需要你的审批,请返回查看请求。',
'browserNotifications.question': '此会话正在等待你的回答,请返回处理。',
'browserNotifications.allow': '允许通知',
'browserNotifications.enable': '开启通知',
'browserNotifications.helpEnabled': '已为此站点开启通知。',
'browserNotifications.helpWaiting': '尚未获得浏览器通知授权。',
'browserNotifications.helpWhen':
'页面在后台或者切换到其他页签时,当前聊天需要审批、等待回答、回合结束或失败会发送提醒,点击通知返回对应聊天,不会自动审批或回答。',
'browserNotifications.helpSystem':
'如果没有收到提醒,请按下方指引检查系统通知',
'browserNotifications.helpMac':
'打开“系统设置 → 通知”,选择浏览器(Chrome 如列有 Google Chrome Helper (Alerts),也需检查),开启允许通知及桌面横幅/提醒。检查专注模式,以及屏幕共享时是否允许通知。',
'browserNotifications.helpWindows':
'打开“设置 → 系统 → 通知”(Windows 10 为“通知和操作”),开启通知及浏览器通知,并允许显示通知横幅。关闭“请勿打扰/专注助手”,或在其规则中允许浏览器通知。',
'browserNotifications.enabled': '已开启。',
'browserNotifications.disabled': '未开启。',
'browserNotifications.waiting': '等待浏览器授权。',
'browserNotifications.denied': '浏览器已阻止通知,请在浏览器站点设置中允许。',
'browserNotifications.unavailable': '当前浏览器或页面环境无法使用通知。',
'browserNotifications.requesting': '等待你的授权…',
'browserNotifications.error': '无法启用或显示通知,请检查浏览器及系统设置。',
'browserNotifications.temporary': '设置仅在当前页面有效。',
};
@@ -630,6 +630,19 @@ describe('build artifact — transcript entry (#11031)', () => {
expect(js.includes('managed.tasks.')).toBe(false);
});
it('keeps browser notification strings and lookups in the interactive entry only', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toContain('browserNotifications.');
const interactive = readPackageJavascript();
expect(interactive).toContain('browserNotifications.helpMac');
expect(interactive).toContain('browserNotifications.approval');
expect(interactive).toContain('Open System Settings → Notifications.');
expect(interactive).toContain('浏览器(Chrome');
});
it('keeps remote Workspace registration copy in the interactive entry', () => {
expect(readPackageJavascript()).toContain('managed.remoteWorkspaceAdd');
expect(readTranscriptBundle()).not.toContain('managed.remoteWorkspace');
@@ -1,9 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import '../styles/standalone.css';
const indexEntry = '../index.tsx';
const { WebShellWithProviders } = await import(/* @vite-ignore */ indexEntry);
import { WebShellWithProviders } from '../index';
const params = new URLSearchParams(window.location.search);
const emptyMobileWelcome = params.get('emptyMobileWelcome') === 'true';
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import '../styles/standalone.css';
import { WebShellWithProviders, WEB_SHELL_SETTING_ITEM_IDS } from '../index';
import type { WebShellSettingItemId } from '../settings';
import type { WebShellTheme } from '../themeContext';
import type {
@@ -8,11 +9,6 @@ import type {
WebShellUnavailableModelTooltipMount,
} from '../unavailableModels';
const indexEntry = '../index.tsx';
const { WebShellWithProviders, WEB_SHELL_SETTING_ITEM_IDS } = await import(
/* @vite-ignore */ indexEntry
);
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('sessionId') ?? 'settings-harness-e2e';
const theme: WebShellTheme = params.get('theme') === 'light' ? 'light' : 'dark';
@@ -2166,7 +2166,9 @@ for (const theme of THEMES) {
// row is required to fit — at this viewport the two rows together are
// taller than the dialog body, so demanding both would fail on geometry
// rather than on a regression.
await inheriting.scrollIntoViewIfNeeded();
await inheriting.evaluate((element) =>
element.scrollIntoView({ block: 'center', inline: 'nearest' }),
);
await expect(inheriting).toBeInViewport({ ratio: 1 });
await captureScreenshot(page, `github-channel-repositories-${theme}`);
});
@@ -215,7 +215,15 @@ test('Settings adds a verified computer and returns to Connections @smoke', asyn
.getByRole('button', { name: /^Connections/ })
.click();
await page.getByLabel('Daemon address').fill(REMOTE_ORIGIN);
await page.getByRole('button', { name: 'Add connection' }).click();
await Promise.all([
page.waitForURL(
(url) =>
url.origin === new URL(sourceUrl).origin &&
url.pathname === '/settings' &&
url.searchParams.get('settings') === 'Connections',
),
page.getByRole('button', { name: 'Add connection' }).click(),
]);
// `?fanout=` names the hosts this document may reach; the shell keeps it in
// the URL so the next load's CSP still covers them, so compare without it.
+48
View File
@@ -18,7 +18,53 @@ const FOLLOWUP_SETTING_KEYS = [
'settings.description.ui.enableFollowupSuggestions',
] as const;
const BROWSER_NOTIFICATION_KEYS = [
'label',
'description',
'prompt',
'reply',
'completed',
'failed',
'ended',
'approval',
'question',
'allow',
'enable',
'helpEnabled',
'helpWaiting',
'helpWhen',
'helpSystem',
'helpMac',
'helpWindows',
'enabled',
'disabled',
'waiting',
'denied',
'unavailable',
'requesting',
'error',
'temporary',
].map((suffix) => `browserNotifications.${suffix}`);
describe('web-shell i18n catalog', () => {
it('keeps every browser notification translation in both locales', () => {
const en = getTranslator('en');
const zh = getTranslator('zh-CN');
for (const key of BROWSER_NOTIFICATION_KEYS) {
const english = en(key, { text: 'Task' });
const chinese = zh(key, { text: 'Task' });
expect(english).not.toBe(key);
expect(chinese).not.toBe(key);
expect(chinese).not.toBe(english);
}
expect(en('browserNotifications.prompt', { text: 'Task' })).toBe(
'Prompt: Task',
);
expect(zh('browserNotifications.reply', { text: 'Task' })).toBe(
'回复:Task',
);
});
it.each([
[
'en',
@@ -50,6 +96,7 @@ describe('web-shell i18n catalog', () => {
}
},
);
it('explains missing rewind identities in both locales', () => {
expect(getTranslator('en')('rewind.identityUnavailable')).toContain(
'no prompt identity',
@@ -81,6 +128,7 @@ describe('web-shell i18n catalog', () => {
'已选 2 组',
);
});
it('keeps task cancellation and elapsed-time copy available in both locales', () => {
const en = getTranslator('en');
const zh = getTranslator('zh-CN');
+7 -68
View File
@@ -68,6 +68,11 @@ import {
MANAGED_TASK_MESSAGES_ZH,
} from './components/managed/managed-task-messages.js';
import {
BROWSER_NOTIFICATION_MESSAGES_EN,
BROWSER_NOTIFICATION_MESSAGES_ZH,
} from './browser-notifications/messages.js';
import {
WORKSPACE_REGISTRATION_MESSAGES_EN,
WORKSPACE_REGISTRATION_MESSAGES_ZH,
@@ -4983,50 +4988,13 @@ const EN: Messages = {
'localControl.disabledHint':
'Local Control is off. Turn it on in Settings to pair a phone on the same network.',
'localControl.openSettings': 'Open Settings',
'browserNotifications.label': 'Task notifications',
'browserNotifications.description':
'Notify when the current chat or a split-view chat finishes, fails, needs approval or waits for your answer while this page is in the background or unfocused. Completed turns show prompt and reply excerpts; action reminders show the session title and waiting status. Saved for this browser site only; the page must remain open.',
'browserNotifications.prompt': (v) => `Prompt: ${v?.text ?? ''}`,
'browserNotifications.reply': (v) => `Reply: ${v?.text ?? ''}`,
'browserNotifications.completed': 'This turn has completed.',
'browserNotifications.failed':
'This turn failed. Return to view the details.',
'browserNotifications.ended':
'This turn has ended. Return to check the result.',
'browserNotifications.approval':
'This session needs your approval. Return to review the request.',
'browserNotifications.question':
'This session is waiting for your answer. Return to respond.',
'browserNotifications.allow': 'Allow notifications',
'browserNotifications.enable': 'Enable notifications',
'browserNotifications.helpEnabled': 'Enabled for this site.',
'browserNotifications.helpWaiting': 'Notifications are not authorized yet.',
'browserNotifications.helpWhen':
'While this page is in the background or you switch to another tab, get reminders when the current chat needs approval, waits for your answer, finishes or fails. Clicking a notification returns to its chat without approving or answering.',
'browserNotifications.helpSystem':
'If no reminders appear, follow the steps below to check system notifications.',
'browserNotifications.helpMac':
'Open System Settings → Notifications. Select your browser (for Chrome, also check Google Chrome Helper (Alerts) if listed), enable notifications and desktop banners/alerts. Check Focus and whether notifications are allowed during screen sharing.',
'browserNotifications.helpWindows':
'Open Settings → System → Notifications (Windows 10: Notifications & actions). Enable notifications and your browser, including notification banners. Turn off Do not disturb / Focus assist, or allow your browser through its rules.',
'browserNotifications.enabled': 'Enabled.',
'browserNotifications.disabled': 'Disabled.',
'browserNotifications.waiting': 'Waiting for browser permission.',
'browserNotifications.denied':
'Notifications are blocked. Allow them in your browser site settings.',
'browserNotifications.unavailable':
'Notifications are unavailable in this browser or page context.',
'browserNotifications.requesting': 'Waiting for your permission…',
'browserNotifications.error':
'Unable to enable or show notifications. Check your browser and system settings.',
'browserNotifications.temporary':
'This setting is saved for the current page only.',
'welcome.changeModel': '(/model to change)',
'welcome.defaultModel': 'unknown model',
'welcome.modeHint': 'Shift+Tab or /approval-mode',
'welcome.prompt': 'What would you like to do?',
'welcome.titlePrefix': 'Welcome to',
'welcome.tipLabel': 'Tips:',
...BROWSER_NOTIFICATION_MESSAGES_EN,
...MCP_APP_MESSAGES_EN,
...VOICE_MESSAGES_EN,
...LIVE_MESSAGES_EN,
@@ -9668,40 +9636,11 @@ const ZH: Messages = {
'localControl.disabledHint':
'本地控制未开启。请在设置中开启后,配对同一网络下的手机。',
'localControl.openSettings': '打开设置',
'browserNotifications.label': '任务通知',
'browserNotifications.description':
'页面在后台或窗口失焦时,提醒当前聊天和分屏聊天的回合结束、失败、需要审批或等待回答。回合结束通知包含提问和回复摘录;待处理提醒只显示会话标题和等待状态。仅保存在此浏览器站点;网页需保持打开。',
'browserNotifications.prompt': (v) => `提问:${v?.text ?? ''}`,
'browserNotifications.reply': (v) => `回复:${v?.text ?? ''}`,
'browserNotifications.completed': '本轮已完成。',
'browserNotifications.failed': '本轮执行失败,请返回查看。',
'browserNotifications.ended': '本轮已结束,请返回查看结果。',
'browserNotifications.approval': '此会话需要你的审批,请返回查看请求。',
'browserNotifications.question': '此会话正在等待你的回答,请返回处理。',
'browserNotifications.allow': '允许通知',
'browserNotifications.enable': '开启通知',
'browserNotifications.helpEnabled': '已为此站点开启通知。',
'browserNotifications.helpWaiting': '尚未获得浏览器通知授权。',
'browserNotifications.helpWhen':
'页面在后台或者切换到其他页签时,当前聊天需要审批、等待回答、回合结束或失败会发送提醒,点击通知返回对应聊天,不会自动审批或回答。',
'browserNotifications.helpSystem':
'如果没有收到提醒,请按下方指引检查系统通知',
'browserNotifications.helpMac':
'打开“系统设置 → 通知”,选择浏览器(Chrome 如列有 Google Chrome Helper (Alerts),也需检查),开启允许通知及桌面横幅/提醒。检查专注模式,以及屏幕共享时是否允许通知。',
'browserNotifications.helpWindows':
'打开“设置 → 系统 → 通知”(Windows 10 为“通知和操作”),开启通知及浏览器通知,并允许显示通知横幅。关闭“请勿打扰/专注助手”,或在其规则中允许浏览器通知。',
'browserNotifications.enabled': '已开启。',
'browserNotifications.disabled': '未开启。',
'browserNotifications.waiting': '等待浏览器授权。',
'browserNotifications.denied': '浏览器已阻止通知,请在浏览器站点设置中允许。',
'browserNotifications.unavailable': '当前浏览器或页面环境无法使用通知。',
'browserNotifications.requesting': '等待你的授权…',
'browserNotifications.error': '无法启用或显示通知,请检查浏览器及系统设置。',
'browserNotifications.temporary': '设置仅在当前页面有效。',
'welcome.modeHint': 'Shift+Tab 或 /approval-mode',
'welcome.prompt': '你想构建什么?',
'welcome.titlePrefix': '欢迎使用',
'welcome.tipLabel': '提示:',
...BROWSER_NOTIFICATION_MESSAGES_ZH,
...MCP_APP_MESSAGES_ZH,
...VOICE_MESSAGES_ZH,
...LIVE_MESSAGES_ZH,
+4
View File
@@ -174,6 +174,10 @@ const TRANSCRIPT_STUBS = new Map(
'./client/trajectory/execution-messages.transcript-stub.ts',
],
['./client/live/messages.ts', './client/live/messages.transcript-stub.ts'],
[
'./client/browser-notifications/messages.ts',
'./client/browser-notifications/messages.transcript-stub.ts',
],
[
'./client/mcp-app/messages.ts',
'./client/mcp-app/messages.transcript-stub.ts',
+17 -5
View File
@@ -85,16 +85,27 @@ java_env() {
export JAVA_OPT_EXT="-Xms${RMQ_HEAP} -Xmx${RMQ_HEAP} -Duser.home=${RMQ_WORK_DIR}/home"
}
download_file() {
log "downloading $2"
curl -fsSL --connect-timeout 10 --max-time 45 --retry 1 \
--retry-max-time 45 -o "$1" "$2"
}
download() {
mkdir -p "${RMQ_CACHE_DIR}"
local zip="${RMQ_CACHE_DIR}/${DIST_NAME}.zip"
local sum="${RMQ_CACHE_DIR}/${DIST_NAME}.zip.sha512"
local base
if [ ! -s "${zip}" ] || [ ! -s "${sum}" ]; then
for base in "https://dlcdn.apache.org/rocketmq/${RMQ_VERSION}" \
"https://archive.apache.org/dist/rocketmq/${RMQ_VERSION}"; do
if curl -fsSL --retry 3 -o "${zip}.part" "${base}/${DIST_NAME}.zip" &&
curl -fsSL --retry 3 -o "${sum}" "${base}/${DIST_NAME}.zip.sha512"; then
if [ ! -s "${zip}" ] || { [ "${RMQ_VERSION}" != "5.5.1" ] && [ ! -s "${sum}" ]; }; then
local bases=("https://dlcdn.apache.org/rocketmq/${RMQ_VERSION}"
"https://archive.apache.org/dist/rocketmq/${RMQ_VERSION}")
if [ "${RMQ_VERSION}" = "5.5.1" ]; then
bases=("https://mirrors.aliyun.com/apache/rocketmq/${RMQ_VERSION}" "${bases[@]}")
fi
for base in "${bases[@]}"; do
if download_file "${zip}.part" "${base}/${DIST_NAME}.zip" &&
{ [ "${RMQ_VERSION}" = "5.5.1" ] ||
download_file "${sum}" "${base}/${DIST_NAME}.zip.sha512"; }; then
mv "${zip}.part" "${zip}"
break
fi
@@ -102,6 +113,7 @@ download() {
done
fi
[ -s "${zip}" ] || die "could not download ${DIST_NAME}.zip"
[ "${RMQ_VERSION}" = "5.5.1" ] || [ -s "${sum}" ] || die "could not download ${DIST_NAME}.zip.sha512"
# Apache publishes the digest in `gpg --print-md` form ("name: HEX HEX …"
# across lines, upper case); sha512sum form is "hex name". Accept both.
local expected actual
+7 -1
View File
@@ -11,6 +11,7 @@ import https from 'node:https';
import { createHash } from 'node:crypto';
import childProcess, { spawnSync } from 'node:child_process';
import { syncBuiltinESMExports } from 'node:module';
import { gzipSync } from 'node:zlib';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
fetchVerifiedArchive,
@@ -351,7 +352,12 @@ async function worker(root, node, bun, expectedHost) {
path.dirname(payload),
'qwen-code',
]);
run('gzip', ['-1', '-f', uncompressedArchive]);
// Avoid repeated compression work for the real interpreter fixtures.
fs.writeFileSync(
archive,
gzipSync(fs.readFileSync(uncompressedArchive), { level: 0 }),
);
fs.rmSync(uncompressedArchive);
sums = `${sha(archive)} ${filename}\n`;
};
const writePayload = (source, version = '1.2.3') => {
+185 -3
View File
@@ -28,15 +28,18 @@
import { execFileSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { delimiter, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
@@ -951,7 +954,7 @@ describe('post-merge push lane', () => {
body,
`${job} must scope the reset to the shard-owned path`,
).toContain(
'if [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then',
'if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then',
);
const reset = body.indexOf('rm -rf -- "${COREPACK_HOME}"');
expect(
@@ -1143,7 +1146,7 @@ describe('post-merge push lane', () => {
(s) => s.name === 'Install dependencies',
);
const body = String(step.run ?? '');
const start = body.indexOf('if [ "${COREPACK_HOME:-}"');
const start = body.indexOf('if [ -n "${RUNNER_TEMP:-}" ]');
const end = body.indexOf('corepack pnpm install');
expect(
start,
@@ -1910,3 +1913,182 @@ describe('platform lanes — a failing nightly is visible', () => {
expect(cond).toContain("workflow_run.conclusion == 'failure'");
});
});
describe('run-local pnpm cache startup', () => {
const install = ci.jobs.test.steps.find(
(step) => step.name === 'Install dependencies',
);
const version = JSON.parse(
readFileSync('package.json', 'utf8'),
).packageManager.match(/^pnpm@([^+]+)/)[1];
// Exercise installation without the unrelated Linux disk sampler.
const [, commandChain] = install.run.split(/\ntrap [^\n]*\n/);
for (const scenario of [
{
name: 'recreates a broken run-local cache before installing',
local: true,
healthy: false,
succeeds: true,
},
{
name: 'recreates a healthy run-local cache before installing',
local: true,
healthy: true,
succeeds: true,
},
{
name: 'fails installation when the fresh package manager is unavailable',
local: true,
healthy: false,
bootstrapFails: true,
succeeds: false,
},
{
name: 'leaves a broken shared cache untouched',
local: false,
healthy: false,
succeeds: false,
},
{
name: 'installs from a healthy shared cache without rewriting it',
local: false,
healthy: true,
succeeds: true,
},
]) {
it(scenario.name, () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'pnpm-startup-')));
const runnerTemp = join(root, 'runner-temp');
const home = scenario.local
? `${runnerTemp}/qwen-ci-corepack`
: join(root, 'shared-corepack');
const cache = join(home, 'v1', 'pnpm', version);
const entry = join(cache, 'bin', 'pnpm.mjs');
const bin = join(root, 'bin');
const seed = join(root, 'pnpm.mjs');
const marker = join(root, 'installed');
const trace = join(root, 'trace.jsonl');
const oldEntryMarker = join(root, 'old-entry-ran');
const sentinel = join(root, 'bystander');
const cacheNeighbor = join(home, 'v1', 'pnpm', 'other-version');
try {
mkdirSync(join(cache, 'bin'), { recursive: true });
mkdirSync(runnerTemp, { recursive: true });
mkdirSync(bin);
writeFileSync(
join(cache, '.corepack'),
'{"bin":{"pnpm":"bin/pnpm.mjs"}}',
);
writeFileSync(sentinel, 'keep');
writeFileSync(cacheNeighbor, 'old-cache');
writeFileSync(
seed,
String.raw`
import { appendFileSync, writeFileSync } from 'node:fs';
const args = process.argv.slice(2);
appendFileSync(process.env.FIXTURE_TRACE, JSON.stringify({ stage: 'pnpm', args }) + '\n');
if (args.length === 1 && args[0] === '--version') {
console.log(process.env.FIXTURE_PINNED_VERSION);
} else if (JSON.stringify(args) === JSON.stringify(['install', '--frozen-lockfile', '--prefer-offline', '--reporter=append-only'])) {
writeFileSync(process.env.FIXTURE_INSTALL_MARKER, 'installed');
} else {
process.exit(2);
}
`,
);
if (scenario.healthy) {
writeFileSync(
entry,
`${readFileSync(seed, 'utf8')}\nwriteFileSync(process.env.FIXTURE_OLD_ENTRY_MARKER, 'ran');\n`,
);
}
writeFileSync(
join(bin, 'corepack'),
String.raw`#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = fs.realpathSync(process.env.FIXTURE_ROOT);
const configuredHome = path.resolve(process.env.COREPACK_HOME);
const parent = fs.realpathSync(path.dirname(configuredHome));
if (parent !== root && !parent.startsWith(root + path.sep)) throw new Error('Not a private fixture parent');
fs.mkdirSync(configuredHome, { recursive: true });
const home = fs.realpathSync(configuredHome);
if (!home.startsWith(root + path.sep)) throw new Error('Not a private fixture');
const args = process.argv.slice(2);
fs.appendFileSync(process.env.FIXTURE_TRACE, JSON.stringify({ stage: 'corepack', args }) + '\n');
if (args[0] !== 'pnpm') throw new Error('Unexpected package manager');
const cache = path.join(home, 'v1', 'pnpm', process.env.FIXTURE_PINNED_VERSION);
const entry = path.join(cache, 'bin', 'pnpm.mjs');
if (!fs.existsSync(cache)) {
fs.appendFileSync(process.env.FIXTURE_TRACE, JSON.stringify({ stage: 'hydrate' }) + '\n');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(path.join(cache, '.corepack'), '{"bin":{"pnpm":"bin/pnpm.mjs"}}');
if (process.env.FIXTURE_BOOTSTRAP_FAILS !== '1') fs.copyFileSync(process.env.FIXTURE_SEED, entry);
}
const result = spawnSync(process.execPath, [entry, ...args.slice(1)], { stdio: 'inherit' });
process.exit(result.status ?? 1);
`,
);
chmodSync(join(bin, 'corepack'), 0o755);
writeFileSync(
join(bin, 'corepack.cmd'),
`@"${process.execPath}" "%~dp0corepack" %*\r\n`,
);
const before = statSync(cache).ino;
const run = () =>
execFileSync('bash', ['-e', '-o', 'pipefail', '-c', commandChain], {
cwd: process.cwd(),
env: {
...process.env,
PATH: `${bin}${delimiter}${process.env.PATH}`,
COREPACK_ENABLE_NETWORK: '0',
COREPACK_HOME: home,
RUNNER_TEMP: runnerTemp,
FIXTURE_ROOT: root,
FIXTURE_TRACE: trace,
FIXTURE_OLD_ENTRY_MARKER: oldEntryMarker,
FIXTURE_PINNED_VERSION: version,
FIXTURE_INSTALL_MARKER: marker,
FIXTURE_SEED: seed,
FIXTURE_BOOTSTRAP_FAILS: scenario.bootstrapFails ? '1' : '0',
},
stdio: 'pipe',
});
expect(commandChain).toBeTruthy();
if (scenario.succeeds) expect(run).not.toThrow();
else expect(run).toThrow();
expect(existsSync(marker)).toBe(scenario.succeeds);
expect(readFileSync(sentinel, 'utf8')).toBe('keep');
expect(existsSync(cacheNeighbor)).toBe(!scenario.local);
expect(existsSync(oldEntryMarker)).toBe(
!scenario.local && scenario.healthy,
);
const installArgs = [
'install',
'--frozen-lockfile',
'--prefer-offline',
'--reporter=append-only',
];
const events = readFileSync(trace, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line));
expect(events).toEqual([
{ stage: 'corepack', args: ['pnpm', ...installArgs] },
...(scenario.local ? [{ stage: 'hydrate' }] : []),
...(scenario.succeeds ? [{ stage: 'pnpm', args: installArgs }] : []),
]);
if (!scenario.local) {
expect(statSync(cache).ino).toBe(before);
expect(readFileSync(join(cache, '.corepack'), 'utf8')).toBe(
'{"bin":{"pnpm":"bin/pnpm.mjs"}}',
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
});
@@ -21,6 +21,7 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { parse } from 'yaml';
import { describe, expect, it } from 'vitest';
@@ -193,6 +194,128 @@ describe('EventTransport broker gates CI', () => {
}
});
it('reselects a broker port when its HA companion exceeds the TCP range', () => {
const dir = mkdtempSync(join(tmpdir(), 'rmq-port-boundary-'));
let holderDir;
try {
const preload = join(dir, 'boundary.mjs');
const observed = join(dir, 'observed');
writeFileSync(
preload,
`import net from 'node:net';
import childProcess from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
const createServer = net.createServer;
const candidates = [41000, 42000, 43000, 65535, 44000];
const allocated = [];
const held = new Set();
const holders = new Map();
const attempts = [];
let nextPid = 1_000_000;
childProcess.spawn = (executable, args) => {
const port = Number(args[2]);
const pid = ++nextPid;
attempts.push(port);
if (port >= 1 && port <= 65535 && !held.has(port)) {
held.add(port);
holders.set(pid, port);
writeFileSync(args[3], String(pid) + '\\n');
}
return { pid, unref() {} };
};
process.kill = (pid) => {
if (!holders.has(pid)) throw new Error('Unknown fixture holder');
held.delete(holders.get(pid));
holders.delete(pid);
return true;
};
syncBuiltinESMExports();
net.createServer = (...args) => {
const server = createServer(...args);
const listen = server.listen;
let selected;
server.listen = function (port, host, callback) {
if (port < 0 || port > 65535) return listen.call(this, port, host, callback);
selected = port === 0 ? candidates.shift() : port;
if (selected === undefined) throw new Error('Candidate sequence exhausted');
if (port === 0) allocated.push(selected);
if (held.has(selected)) {
queueMicrotask(() => server.emit('error', new Error('Port in use')));
} else {
held.add(selected);
queueMicrotask(callback);
}
return server;
};
server.address = () => ({ port: selected });
server.close = () => {
held.delete(selected);
return server;
};
return server;
};
process.on('exit', () => writeFileSync(${JSON.stringify(observed)}, JSON.stringify({ allocated, attempts, held: [...held] })));
`,
);
const result = spawnSync(
process.execPath,
[
'--import',
pathToFileURL(preload).href,
'.github/scripts/choose-rocketmq-ports.mjs',
],
{ encoding: 'utf8', timeout: 10_000 },
);
const chosen = Object.fromEntries(
result.stdout
.trim()
.split('\n')
.map((line) => line.split('=')),
);
holderDir = chosen.RMQ_HOLDER_DIR;
expect(result.status, result.stderr).toBe(0);
const reservation = JSON.parse(readFileSync(observed, 'utf8'));
expect(reservation.allocated).toEqual([
41000, 42000, 43000, 65535, 44000,
]);
expect(reservation.attempts).not.toContain(65536);
expect(reservation.held.sort((a, b) => a - b)).toEqual([
41000, 42000, 43000, 43998, 44000, 44001,
]);
expect(Object.keys(chosen).sort()).toEqual(
[
'RMQ_HOLDER_DIR',
'RMQ_NAMESRV_PORT',
'RMQ_BROKER_PORT',
'RMQ_PROXY_GRPC_PORT',
'RMQ_PROXY_REMOTING_PORT',
].sort(),
);
const ports = [
'RMQ_NAMESRV_PORT',
'RMQ_BROKER_PORT',
'RMQ_PROXY_GRPC_PORT',
'RMQ_PROXY_REMOTING_PORT',
].map((name) => Number(chosen[name]));
expect(new Set(ports).size).toBe(4);
for (const port of ports) {
expect(Number.isInteger(port)).toBe(true);
expect(port).toBeGreaterThanOrEqual(1);
expect(port).toBeLessThanOrEqual(65535);
}
const broker = Number(chosen.RMQ_BROKER_PORT);
expect(broker).toBe(44000);
expect(broker - 2).toBeGreaterThanOrEqual(1);
expect(broker + 1).toBeLessThanOrEqual(65535);
expect(ports).not.toContain(broker - 2);
expect(ports).not.toContain(broker + 1);
} finally {
if (holderDir) rmSync(holderDir, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
it('still runs the gate on its only live triggers', () => {
// Keep both daily coverage and the release caller after leaving PR CI.
expect(Object.keys(workflow.on).sort()).toEqual([
@@ -1159,6 +1159,94 @@ test('rejects stale or ambiguous safety defaults', async (context) => {
}
});
test('verifies archives in fresh directories on a reused runner', async () => {
const workflow = await readFile(
new URL(
'../../.github/workflows/release-managed-agent-reference.yml',
import.meta.url,
),
'utf8',
);
const stepStart = workflow.indexOf(
" - name: 'Verify and boot the exact archive'",
);
const runMarker = ' run: |-\n';
const runStart = workflow.indexOf(runMarker, stepStart);
const boundary = workflow.indexOf(' server_port=', runStart);
assert.ok(stepStart >= 0);
assert.ok(runStart > stepStart);
assert.ok(boundary > runStart);
const script = workflow
.slice(runStart + runMarker.length, boundary)
.replace(/^ {10}/gm, '');
const temporary = await mkdtemp(
path.join(os.tmpdir(), 'qwen-reference pool-'),
);
try {
const root = path.join(temporary, 'repo');
const output = path.join(temporary, 'managed-agent-reference');
const version = '0.1.0-alpha.1';
const name = `qwen-managed-agent-reference-${version}`;
await fixture(root);
// The archive probe reads migrations before Java starts.
const serverJar = Buffer.from(
'UEsDBBQAAAAAAAAAIQA5b9fWCQAAAAkAAAAzAAAAQk9PVC1JTkYvY2xhc3Nlcy9kYi9tYW5hZ2Vk' +
'LXNjaGVtYS9WMV9fYmFzZWxpbmUuc3Fsc2VsZWN0IDE7UEsBAhQDFAAAAAAAAAAhADlv19YJAAAA' +
'CQAAADMAAAAAAAAAAAAAAIABAAAAAEJPT1QtSU5GL2NsYXNzZXMvZGIvbWFuYWdlZC1zY2hlbWEv' +
'VjFfX2Jhc2VsaW5lLnNxbFBLBQYAAAAAAQABAGEAAABaAAAAAAA=',
'base64',
);
await writeFile(
path.join(
root,
'packages/managed-agent-server/server/target/qwen-managed-agent-server-0.4.0-alpha-exec.jar',
),
serverJar,
);
const upgradeModule =
'packages/managed-agent-server/distribution/bin/qwen-managed-agent-upgrade.mjs';
await writeFile(
path.join(root, upgradeModule),
await readFile(new URL(`../../${upgradeModule}`, import.meta.url)),
);
await createReferenceDistribution({
repoRoot: root,
outputDirectory: output,
version,
sourceRevision: 'a'.repeat(40),
});
const legacy = path.join(output, 'extracted', name, 'stale.txt');
await mkdir(path.dirname(legacy), { recursive: true });
await writeFile(legacy, 'previous job');
const bundles = [];
for (let attempt = 0; attempt < 2; attempt++) {
const result = execFileSync(
'bash',
['-c', `${script}\nprintf '%s\\n' "$PWD"`],
{
env: {
...process.env,
RUNNER_TEMP: temporary,
RELEASE_VERSION: version,
},
encoding: 'utf8',
},
);
const bundle = result.trim().split('\n').at(-1);
assert.equal(path.basename(bundle), name);
assert.ok(!bundles.includes(bundle));
await assert.rejects(readFile(path.join(bundle, 'stale.txt')), {
code: 'ENOENT',
});
await writeFile(path.join(bundle, 'stale.txt'), 'current job');
bundles.push(bundle);
}
assert.equal(await readFile(legacy, 'utf8'), 'previous job');
} finally {
await rm(temporary, { recursive: true, force: true });
}
});
test('publishes verified draft assets behind an exact tag', async () => {
const workflow = await readFile(
new URL(
@@ -13,6 +13,7 @@
// lines — plus a stand-in mqadmin, so no RocketMQ download is needed.
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
chmodSync,
existsSync,
@@ -344,3 +345,162 @@ describe.skipIf(!portLookup)('rocketmq-lite-broker.sh process handling', () => {
}
}, 200_000);
});
describe.skipIf(process.platform === 'win32')(
'RocketMQ release download',
() => {
let work;
let env;
const archive = Buffer.from(
'UEsDBBQAAAAAAO2JW13qftiREQAAABEAAAASAAAAZG93bmxvYWQtcHJvb2YudHh0dmVyaWZpZWQgcmVsZWFzZQpQSwECFAMUAAAAAADtiVtd6n7YkREAAAARAAAAEgAAAAAAAAAAAAAAgAEAAAAAZG93bmxvYWQtcHJvb2YudHh0UEsFBgAAAAABAAEAQAAAAEEAAAAAAA==',
'base64',
);
// Exercise the real downloader without starting Java or a broker.
const definitions = readFileSync(script, 'utf8').split(
'\nwrite_config() {',
)[0];
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), 'rmq-download-'));
mkdirSync(join(work, 'bin'));
writeFileSync(join(work, 'release.zip'), archive);
writeFileSync(
join(work, 'curl.mjs'),
`import { appendFileSync, copyFileSync, writeFileSync } from 'node:fs';
const args = process.argv.slice(2);
const url = args.at(-1);
const output = args[args.indexOf('-o') + 1];
appendFileSync(process.env.REQUESTS, JSON.stringify(args) + '\\n');
for (const [flag, value] of [['--connect-timeout', '10'], ['--max-time', '45'], ['--retry', '1'], ['--retry-max-time', '45']]) {
if (args[args.indexOf(flag) + 1] !== value) process.exit(95);
}
if (process.env.FAIL_ALL || url.includes('dlcdn.apache.org') ||
(process.env.FAIL_MIRROR && url.includes('mirrors.aliyun.com'))) {
writeFileSync(output, 'partial response');
process.exit(28);
}
if (url.endsWith('.sha512')) writeFileSync(output, process.env.PUBLISHED_SUM);
else copyFileSync(process.env.RELEASE, output);
`,
);
writeFileSync(
join(work, 'bin', 'curl'),
`#!/usr/bin/env bash\nexec "${process.execPath}" "${join(work, 'curl.mjs')}" "$@"\n`,
);
chmodSync(join(work, 'bin', 'curl'), 0o755);
const hash = createHash('sha512').update(archive).digest('hex');
env = {
...process.env,
PATH: `${join(work, 'bin')}:${process.env.PATH}`,
RMQ_VERSION: 'test-release',
RMQ_WORK_DIR: work,
RMQ_CACHE_DIR: join(work, 'cache'),
RELEASE: join(work, 'release.zip'),
REQUESTS: join(work, 'requests.jsonl'),
PUBLISHED_SUM: `${hash} release.zip\n`,
FAIL_ALL: '',
FAIL_MIRROR: '',
};
});
afterEach(() => rmSync(work, { recursive: true, force: true }));
const run = (extra = {}) =>
spawnSync('bash', ['-c', `${definitions}\ndownload`], {
env: { ...env, ...extra },
encoding: 'utf8',
timeout: 10_000,
});
const requests = () =>
readFileSync(env.REQUESTS, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line).at(-1));
it.each(['sha512sum', 'gpg'])(
'falls back after timeout and verifies %s checksums',
(format) => {
if (format === 'gpg') {
const hash = createHash('sha512').update(archive).digest('hex');
env.PUBLISHED_SUM = `release.zip: ${hash
.toUpperCase()
.match(/.{1,16}/g)
.join(' ')}\n`;
}
const result = run();
expect(result.status, result.stderr).toBe(0);
expect(requests()).toEqual([
'https://dlcdn.apache.org/rocketmq/test-release/rocketmq-all-test-release-bin-release.zip',
'https://archive.apache.org/dist/rocketmq/test-release/rocketmq-all-test-release-bin-release.zip',
'https://archive.apache.org/dist/rocketmq/test-release/rocketmq-all-test-release-bin-release.zip.sha512',
]);
expect(result.stderr).toContain(
'downloading https://archive.apache.org/',
);
expect(readFileSync(join(work, 'download-proof.txt'), 'utf8')).toBe(
'verified release\n',
);
},
);
it('rejects a downloaded archive with a mismatched checksum before extraction', () => {
const result = run({ PUBLISHED_SUM: '0'.repeat(128) });
expect(result.status).toBe(1);
expect(result.stderr).toContain('failed its SHA-512 check');
expect(existsSync(join(work, 'download-proof.txt'))).toBe(false);
});
it('cleans partial responses when both sources fail', () => {
const result = run({ FAIL_ALL: '1' });
expect(result.status).toBe(1);
expect(result.stderr).toContain('could not download');
expect(requests()).toHaveLength(2);
expect(readdirSync(env.RMQ_CACHE_DIR)).toEqual([]);
expect(existsSync(join(work, 'download-proof.txt'))).toBe(false);
});
it('does not fetch a redundant checksum for the pinned release', () => {
const result = run({ RMQ_VERSION: '5.5.1' });
expect(result.status).toBe(1);
expect(result.stderr).toContain('failed its SHA-512 check');
expect(requests()).toEqual([
'https://mirrors.aliyun.com/apache/rocketmq/5.5.1/rocketmq-all-5.5.1-bin-release.zip',
]);
expect(requests().every((url) => url.endsWith('.zip'))).toBe(true);
expect(existsSync(join(work, 'download-proof.txt'))).toBe(false);
});
it('falls back to Apache when the pinned mirror fails', () => {
const result = run({ RMQ_VERSION: '5.5.1', FAIL_MIRROR: '1' });
expect(result.status).toBe(1);
expect(result.stderr).toContain('failed its SHA-512 check');
expect(requests()).toEqual([
'https://mirrors.aliyun.com/apache/rocketmq/5.5.1/rocketmq-all-5.5.1-bin-release.zip',
'https://dlcdn.apache.org/rocketmq/5.5.1/rocketmq-all-5.5.1-bin-release.zip',
'https://archive.apache.org/dist/rocketmq/5.5.1/rocketmq-all-5.5.1-bin-release.zip',
]);
expect(existsSync(join(work, 'download-proof.txt'))).toBe(false);
});
it('checks a cached pinned archive without fetching a checksum', () => {
mkdirSync(env.RMQ_CACHE_DIR);
writeFileSync(
join(env.RMQ_CACHE_DIR, 'rocketmq-all-5.5.1-bin-release.zip'),
archive,
);
const result = run({ RMQ_VERSION: '5.5.1' });
expect(result.status).toBe(1);
expect(result.stderr).toContain('failed its SHA-512 check');
expect(existsSync(env.REQUESTS)).toBe(false);
expect(existsSync(join(work, 'download-proof.txt'))).toBe(false);
});
},
);
+158
View File
@@ -0,0 +1,158 @@
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
const fixtures = [];
afterEach(() => {
for (const base of fixtures.splice(0)) {
spawnSync('chmod', ['-R', 'u+rwX', base]);
rmSync(base, { recursive: true, force: true });
}
});
function fixture() {
const base = mkdtempSync(join(tmpdir(), 'java-tool-cache-'));
fixtures.push(base);
const work = join(base, '_work');
const cache = join(work, '_tool');
const temp = join(work, '_temp');
const java = join(cache, 'Java_Temurin-Hotspot_jdk');
const node = join(cache, 'node');
const bin = join(base, 'bin');
const log = join(base, 'commands');
for (const path of [temp, java, node, bin]) {
mkdirSync(path, { recursive: true });
}
const realChown = spawnSync('which', ['chown'], {
encoding: 'utf8',
}).stdout.trim();
writeFileSync(
join(bin, 'chown'),
'#!/bin/bash\necho chown >> "$COMMAND_LOG"\n' +
'[ "${DENY_CHOWN:-0}" != 1 ] || exit 1\n' +
'exec "$REAL_CHOWN" "$@"\n',
{ mode: 0o755 },
);
writeFileSync(
join(bin, 'sudo'),
'#!/bin/bash\necho "sudo:$*" >> "$COMMAND_LOG"\n' +
'[ "${DENY_SUDO:-0}" != 1 ] || exit 1\n' +
'[ "$1" = -n ] || exit 2\nshift\n' +
'if [ "$1" = chown ]; then shift; exec "$REAL_CHOWN" "$@"; fi\n' +
'exec "$@"\n',
{ mode: 0o755 },
);
const run = (env = {}) =>
spawnSync('bash', [resolve('.github/scripts/restore-java-tool-cache.sh')], {
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
RUNNER_TOOL_CACHE: cache,
RUNNER_TEMP: temp,
COMMAND_LOG: log,
REAL_CHOWN: realChown,
...env,
},
encoding: 'utf8',
});
return { base, cache, java, node, log, run };
}
describe.skipIf(process.platform === 'win32')(
'runner-local Java tool-cache repair',
() => {
it('leaves an absent tool cache for setup-java to create', () => {
const f = fixture();
rmSync(f.cache, { recursive: true });
const result = f.run();
expect(result.status, result.stderr).toBe(0);
expect(existsSync(f.cache)).toBe(false);
expect(existsSync(f.log)).toBe(false);
});
it('creates a missing Java subtree without changing another tool cache', () => {
const f = fixture();
rmSync(f.java, { recursive: true });
const result = f.run();
expect(result.status, result.stderr).toBe(0);
expect(statSync(f.java).isDirectory()).toBe(true);
expect(statSync(f.node).isDirectory()).toBe(true);
expect(readFileSync(f.log, 'utf8')).not.toContain('sudo:');
});
it('restores JDK writes and retains every version without touching Node or linked targets', () => {
const f = fixture();
const outside = join(f.base, 'outside');
mkdirSync(outside);
writeFileSync(join(outside, 'marker'), 'outside');
chmodSync(outside, 0o555);
chmodSync(f.node, 0o555);
symlinkSync(outside, join(f.java, 'linked-target'));
for (const version of ['11.0.32-101', '17.0.20-101', '21.0.12-101']) {
const parent = join(f.java, version);
mkdirSync(join(parent, 'arm64'), { recursive: true });
writeFileSync(join(parent, 'arm64', 'marker'), version);
chmodSync(parent, 0o555);
}
const result = f.run();
expect(result.status, result.stderr).toBe(0);
for (const version of ['11.0.32-101', '17.0.20-101', '21.0.12-101']) {
const parent = join(f.java, version);
expect(readFileSync(join(parent, 'arm64', 'marker'), 'utf8')).toBe(
version,
);
expect(statSync(parent).mode & 0o200).toBe(0o200);
writeFileSync(join(parent, 'new-marker'), 'writable');
}
expect(statSync(f.node).mode & 0o777).toBe(0o555);
expect(statSync(outside).mode & 0o777).toBe(0o555);
expect(readFileSync(f.log, 'utf8')).not.toContain('sudo:');
});
it.each(['external-cache', 'cache-symlink', 'java-symlink'])(
'rejects %s before any ownership change',
(kind) => {
const f = fixture();
const outside = join(f.base, 'outside');
mkdirSync(outside);
const env = {};
if (kind === 'external-cache') {
env.RUNNER_TOOL_CACHE = outside;
} else {
const target = kind === 'cache-symlink' ? f.cache : f.java;
rmSync(target, { recursive: true });
symlinkSync(outside, target);
}
const result = f.run(env);
expect(result.status).not.toBe(0);
expect(result.stdout).toContain('::error::Refusing');
expect(existsSync(f.log)).toBe(false);
},
);
it('uses noninteractive sudo only after ordinary ownership repair fails', () => {
const f = fixture();
const result = f.run({ DENY_CHOWN: '1' });
expect(result.status, result.stderr).toBe(0);
expect(readFileSync(f.log, 'utf8')).toContain('sudo:-n chown -hR');
});
it('fails clearly when ownership repair requires unavailable sudo', () => {
const f = fixture();
const result = f.run({ DENY_CHOWN: '1', DENY_SUDO: '1' });
expect(result.status).not.toBe(0);
expect(result.stdout).toContain('::error::Could not restore');
});
},
);
+40
View File
@@ -2569,6 +2569,46 @@ describe('setup-java Maven cache stays on hosted runners', () => {
});
});
describe('SDK Java runner-local tool-cache repair', () => {
it('repairs only default local caches without gating Java installation', () => {
for (const [name, job] of Object.entries(parse(workflow).jobs)) {
const steps = job.steps ?? [];
const java = steps.findIndex((s) =>
String(s.uses ?? '').startsWith('actions/setup-java'),
);
const helper = '.github/scripts/restore-java-tool-cache.sh';
const repair = steps.findIndex((s) =>
String(s.run ?? '').includes(helper),
);
if (java < 0 || !String(job['runs-on']).includes('self-hosted')) {
expect(repair, name).toBe(-1);
continue;
}
// A resumed release checks out an older tag that predates this helper,
// so the step must probe for it before invoking bash.
expect(steps[repair].run, name).toContain(`[ -f ${helper} ]`);
const checkout = steps.findIndex((s) =>
String(s.uses ?? '').startsWith('actions/checkout'),
);
expect(repair, name).toBeGreaterThan(checkout);
if (name === 'daemon-e2e') {
expect(repair, name).toBe(java - 2);
expect(steps[repair + 1].name, name).toBe(
'Use pre-installed Node.js (self-hosted)',
);
} else {
expect(repair, name).toBe(java - 1);
}
expect(steps[repair].if, name).toBe(
"${{ runner.environment == 'self-hosted' && endsWith(runner.tool_cache, '/_tool') }}",
);
expect(steps[repair]['continue-on-error'], name).toBeUndefined();
expect(steps[java].if, name).toBeUndefined();
expect(steps[java].env?.RUNNER_TOOL_CACHE, name).toBeUndefined();
}
});
});
// A bare `corepack pnpm install` has no retry. Run 39348512071's daemon E2E
// lane died in that step before any test ran, inside the workspace build the
// install's `prepare` re-enters: packages/cli's `tsc --build` exhausted V8's