Files
qwen-code/scripts/tests/hosted-process-ci.test.js
chencheng (云谦) 1b1f4455d9 ci: build the PR tree once and shorten the required path (#16450)
* ci: build the PR tree once and shorten the required path

The no-AK integration gate, the four web-shell smoke shards and Lint & Static each rebuilt the tree test_build already uploads, and the build cache rarely helped: most runs logged "29 of 29 workspaces changed" because the arch-keyed cache was split across the mixed x64/ARM64 pool and a prefix restore returned the newest entry instead of the base commit's.

- test_build restores the build its base commit saved (ci-build-cache.mjs key --at), drops runner.arch from the key, and runs the serve fast-path closure and core subpath checks against the built tree.
- The no-AK integration gate unpacks that tree and runs as three shards; integration_no_ak_result keeps the required check name.
- The web-shell smoke unpacks the same tree instead of building; consumers recopy the opentui native assets for their host (host-assets).
- The standalone runtime regression moves from unit shard 1 to shard 4.
- A java_only profile skips the TypeScript lanes for changes confined to the Java packages; Lint & Static runs the scripts/tests suites that name them.
- Drafts skip the integration gate and the browser smoke; ready_for_review starts the full run.

* ci: check the settings schema in test_build, which has the core dist

Lint & Static only had core's dist because the serve fast-path check built
it; with that check moved, Generate settings schema failed to import it.

* ci: keep the standalone regression on unit shard 1

Moving it did not shorten shard 1: the regression runs in the side lane,
in parallel with the CLI and core suites, and shard 1's time is its CLI
slice (576 s against 147 s on shard 4 for the same 181 files). That
imbalance is left to duration-based sharding.
2026-11-06 03:30:11 -08:00

1479 lines
67 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { parse } from 'yaml';
import { describe, expect, it } from 'vitest';
import { classifyChangedFiles } from '../../.github/scripts/ci/classify-profile.mjs';
const read = (file) =>
readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
const ci = parse(read('.github/workflows/ci.yml'));
const java = parse(read('.github/workflows/sdk-java.yml'));
const pkg = JSON.parse(read('package.json'));
const focused = 'test:integration:hosted:sandbox:none';
const windowsRun = [
`npm run ${focused} -- --reporter=default --reporter=json --outputFile.json=hosted-process-suite.json`,
`node -e "const r = require('./integration-tests/hosted-process-suite.json'); if (r.numTotalTests === 0 || r.numPassedTests !== r.numTotalTests) { console.error('::error::' + r.numPassedTests + ' of ' + r.numTotalTests + ' Hosted cases passed; Windows must run every case'); process.exit(1); }"`,
].join('\n');
// One row per failover arm the Hosted failover job runs: step name, npm
// script, and the runner flags that make the row a different arm.
const failoverArms = [
[
'Run in-flight owner failover E2E',
'test:e2e:managed-inflight-failover',
['--inflight-failover'],
],
[
'Run continuation owner failover E2E',
'test:e2e:managed-continuation-failover',
['--continuation-failover'],
],
[
'Run session owner failover E2E',
'test:e2e:managed-session-failover',
['--session-failover'],
],
[
'Run long-answer persistence E2E',
'test:e2e:managed-big-output',
['--big-output'],
],
[
'Run Harness-restart session failover E2E',
'test:e2e:managed-harness-restart-failover',
['--session-failover', '--harness-only'],
],
[
'Run Harness-restart in-flight failover E2E',
'test:e2e:managed-harness-restart-inflight-failover',
['--inflight-failover', '--harness-only'],
],
[
'Run Harness-restart continuation failover E2E',
'test:e2e:managed-harness-restart-continuation-failover',
['--continuation-failover', '--harness-only'],
],
[
'Run frozen former-owner fencing E2E',
'test:e2e:managed-continuation-frozen-owner-failover',
['--continuation-failover', '--freeze'],
],
[
'Run Harness-restart approval Allow recovery E2E',
'test:e2e:managed-harness-restart-approval-allow',
['--continuation-failover', '--harness-only', '--g3-verify allow'],
],
[
'Run Harness-restart approval Deny recovery E2E',
'test:e2e:managed-harness-restart-approval-deny',
['--continuation-failover', '--harness-only', '--g3-verify deny'],
],
[
'Run Harness-restart approval Cancel recovery E2E',
'test:e2e:managed-harness-restart-approval-cancel',
['--continuation-failover', '--harness-only', '--g3-verify cancel'],
],
[
'Run Harness-restart approval expiry recovery E2E',
'test:e2e:managed-harness-restart-approval-expiry',
['--continuation-failover', '--harness-only', '--g3-verify expiry'],
],
[
'Run approval post-decision crash E2E',
'test:e2e:managed-harness-restart-approval-after-decision',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'after-decision',
],
],
[
'Run approval partial-prepare crash E2E',
'test:e2e:managed-harness-restart-approval-after-prepare',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'after-prepare',
],
],
[
'Run approval result-commit crash E2E',
'test:e2e:managed-harness-restart-approval-after-result',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'after-result',
],
],
[
'Run finished-before-commit crash E2E',
'test:e2e:managed-harness-restart-approval-finished-before-commit',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'finished-before-commit',
],
],
[
'Run sibling-approval crash E2E',
'test:e2e:managed-harness-restart-approval-sibling-approval',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'sibling-approval',
],
],
[
'Run after-earlier-tools crash E2E',
'test:e2e:managed-harness-restart-approval-after-earlier-tools',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'after-earlier-tools',
],
],
[
'Run approval decision reply-loss E2E',
'test:e2e:managed-harness-restart-approval-decision-reply-lost',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'decision-reply-lost',
],
],
[
'Run Turn admission reply-loss E2E',
'test:e2e:managed-harness-restart-approval-admission-reply-lost',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'admission-reply-lost',
],
],
[
'Run takeover load reply-loss E2E',
'test:e2e:managed-harness-restart-approval-load-reply-lost',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'load-reply-lost',
],
],
[
'Run committed terminal reply-loss E2E',
'test:e2e:managed-harness-restart-approval-terminal-reply-lost',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'terminal-reply-lost',
],
],
[
'Run missing terminal write E2E',
'test:e2e:managed-harness-restart-approval-terminal-write-failed',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'terminal-write-failed',
],
],
[
'Run refused cleanup release E2E',
'test:e2e:managed-harness-restart-approval-cleanup-failed',
[
'--continuation-failover',
'--harness-only',
'--g3-verify',
'allow',
'--g3-fault',
'cleanup-failed',
],
],
[
'Run Harness-restart continuation closeout E2E',
'test:e2e:managed-harness-restart-continuation-closeout',
['--continuation-failover', '--harness-only', '--g3-verify continuation'],
],
[
'Run Harness-restart model-round failover E2E',
'test:e2e:managed-harness-restart-model-round-failover',
['--model-round-failover', '--harness-only'],
],
[
'Run frozen former control-plane fencing E2E',
'test:e2e:managed-frozen-control-plane-failover',
['--model-round-failover', '--frozen-control-plane'],
],
];
describe('Hosted real-process gates', () => {
it('runs the packaged suite on relevant PRs without credentials or optional prerequisites', () => {
const job = ci.jobs.integration_no_ak;
expect(ci.on).toHaveProperty('pull_request');
expect(job.if).toContain("github.event_name == 'pull_request'");
expect(job.if).toContain("github.event_name == 'merge_group'");
const install = job.steps.find(
(step) => step.name === 'Install Dependencies',
);
expect(install.run).toContain('corepack pnpm install --frozen-lockfile');
// The packaged CLI comes from test_build's tree, built and bundled once
// for every lane, not from `prepare` during this job's install.
expect(install.env?.QWEN_SKIP_PREPARE).toBe('1');
expect(job.needs).toContain('test_build');
const download = job.steps.find(
(step) => step.name === 'Download the built tree',
);
expect(download.with.name).toBe('unit-test-tree');
expect(
job.steps.find((step) => step.name === 'Unpack the built tree').run,
).toContain('unit-test-tree.tgz');
expect(
ci.jobs.test_build.steps.find((step) => step.name === 'Build').run,
).toBe('node scripts/ci-build-cache.mjs build .qwen-build-cache');
const buildCache = read('scripts/ci-build-cache.mjs');
expect(buildCache).toContain("run('npm run build')");
expect(buildCache).toContain("run('npm run bundle')");
const run = job.steps.find(
(step) => step.name === 'Run required no-AK integration gate',
);
expect(run.if).toContain("ci_profile == 'full'");
expect(run.run).toContain('npm run test:integration:no-ak:sandbox:none');
expect(run['continue-on-error']).toBeUndefined();
expect(run.run).not.toContain('|| true');
expect(pkg.scripts['test:integration:no-ak:sandbox:none']).toContain(
'./cli/hosted-harness-process.test.ts',
);
expect(pkg.scripts[focused]).toBe(
'cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --config ./vitest.hosted.config.ts',
);
expect(read('integration-tests/vitest.hosted.config.ts')).not.toContain(
'testNamePattern',
);
// Any unlisted path classifies as full, so the check means something only
// for paths that exist: none of them may take a PR out of the full profile.
expect(
classifyChangedFiles([
'docs/design/2026-09-26-hosted-no-tool-process-gate.md',
]),
).toBe('docs_only');
for (const file of [
'integration-tests/cli/hosted-harness-process.test.ts',
'integration-tests/helpers/hosted-harness-process.ts',
'integration-tests/helpers/hosted-session-store.ts',
'packages/cli/src/serve/hosted-harness-model.ts',
'.github/workflows/ci.yml',
]) {
expect(existsSync(new URL(`../../${file}`, import.meta.url)), file).toBe(
true,
);
expect(classifyChangedFiles([file]), file).toBe('full');
}
});
it('keeps the portable smoke filter matched to real cases', () => {
// vitest exits 0 when -t matches nothing, so renaming these titles would
// turn the macOS smoke into a silent no-op.
const source = read('integration-tests/cli/hosted-harness-process.test.ts');
expect(source.match(/\bit\(\s*'portable startup:/g)).toHaveLength(2);
// The Windows lane runs every case, so none may skip itself, by a
// modifier or an options object.
expect(source).not.toMatch(
/\.(skip|skipIf|runIf|todo|only)\b|\b(skip|todo|only)\s*:/,
);
});
it.each([
{
name: 'test_macos_shards',
step: 'Hosted portable process smoke',
after: /Install dependencies/i,
condition:
"${{ needs.classify_pr.outputs.skip_ci != 'true' && matrix.group == 'scripts' }}",
run: `npm run ${focused} -- -t 'portable startup'`,
minutes: 5,
},
{
// After the unit tests, so that a Hosted failure cannot skip them, and
// failing unless every collected case passed.
name: 'test_windows',
step: 'Hosted process suite',
after: /^Run tests and generate reports$/,
condition:
"${{ !cancelled() && steps.install.outcome == 'success' && needs.classify_pr.outputs.skip_ci != 'true' }}",
run: windowsRun,
minutes: 10,
},
])(
'runs the Hosted process cases on $name',
({ name, step: stepName, after, condition, run, minutes }) => {
const steps = ci.jobs[name].steps;
const index = steps.findIndex((step) => step.name === stepName);
expect(index).toBeGreaterThan(
steps.findIndex((step) => after.test(step.name ?? '')),
);
expect(steps[index].if).toBe(condition);
expect(steps[index].run).toBe(run);
expect(steps[index]['timeout-minutes']).toBe(minutes);
expect(steps[index]['continue-on-error']).toBeUndefined();
},
);
it.each(['test_windows', 'test_macos_shards'])(
'gates the %s suite on a successful install',
(name) => {
// An unknown step id reads as '' and silently disables the condition
// above, so the suite would never run again.
expect(
ci.jobs[name].steps.find((step) => step.name === 'Install dependencies')
.id,
).toBe('install');
},
);
it('keeps Hosted MySQL separate from the non-Hosted MySQL slice and fails on missing tests', () => {
const job = java.jobs['hosted-harness-mysql'];
expect(job.services.mysql.image).toBe('mysql:8.4.6');
expect(job.services.mysql.ports).toEqual(['3306/tcp']);
expect(job.if).toBe("${{ inputs.tier != 'fast' }}");
// Hosted*IT executes only in the separate Hosted lane.
const nonHostedLane = java.jobs['mysql-integration'];
expect(nonHostedLane.strategy).toBeUndefined();
expect(
nonHostedLane.steps.find(
(step) => step.name === 'Start the database without native AIO',
).run,
).toContain('mysql:8.4.6 --innodb-use-native-aio=0 --skip-log-bin');
// Keep daily coverage and run the same suite before Java publication.
expect(Object.keys(java.on).sort()).toEqual([
'schedule',
'workflow_call',
'workflow_dispatch',
]);
const run = job.steps.find(
(step) => step.name === 'Verify Hosted Java, Spring and MySQL processes',
);
expect(run.run).toContain('-Phosted-harness-mysql');
expect(run.run).toContain('-Dnode.executable="$(command -v node)"');
// The profile's include selects the tests; -Dit.test would override it.
expect(run.run).not.toContain('-Dit.test');
expect(run.run).toContain(
'-Dqwen.cli.entry="${GITHUB_WORKSPACE}/dist/cli.js"',
);
expect(run.run).toContain('clean verify checkstyle:check');
expect(run.run).not.toContain('skip');
expect(run['continue-on-error']).toBeUndefined();
// The relay-header filter otherwise surfaces only as an intermittent
// socket-reuse failure in the Maven gate below, so the direct check must
// keep running first.
const relay = job.steps.findIndex(
(step) => step.name === 'Check Hosted proxy header relay',
);
expect(relay).toBeGreaterThan(-1);
expect(job.steps[relay].run).toBe(
'cd integration-tests\n' +
'npx vitest run helpers/hosted-relay-headers.test.ts',
);
expect(job.steps[relay]['continue-on-error']).toBeUndefined();
expect(job.steps[relay].if).toBeUndefined();
expect(job.steps[relay].shell).toBeUndefined();
expect(job.steps[relay]['working-directory']).toBeUndefined();
expect(relay).toBeLessThan(job.steps.indexOf(run));
const pom = read(
'packages/managed-agent-server/verification/pom.xml',
).replace(/<!--[\s\S]*?-->/g, '');
// The report check sees a test selection only when Maven passes it as a
// user property. Set in the workflow (MAVEN_OPTS included) or in a POM,
// it would narrow the run unrecorded.
const selection = /it\.test|includesFile|excludesFile|<test>/;
expect(read('.github/workflows/sdk-java.yml')).not.toMatch(selection);
expect(pom).not.toMatch(selection);
const [nonHosted, hosted] = pom.split('<id>hosted-harness-mysql</id>');
expect(nonHosted).toContain('<id>mysql-integration</id>');
expect(nonHosted).toContain('<exclude>**/Hosted*IT.java</exclude>');
expect(hosted).toContain('<include>**/Hosted*IT.java</include>');
expect(hosted).toContain('<failIfNoTests>true</failIfNoTests>');
const isolationProfile = hosted.split('</profile>')[0];
// Scope the parameter assertions to the failsafe plugin itself: a
// <properties> block hoisted above the plugin would keep the profile
// slice matching while never reaching the fork.
const failsafeStart = isolationProfile.indexOf(
'<artifactId>maven-failsafe-plugin</artifactId>',
);
expect(failsafeStart).toBeGreaterThan(-1);
const failsafeEnd = isolationProfile.indexOf('</plugin>', failsafeStart);
expect(failsafeEnd).toBeGreaterThan(failsafeStart);
const failsafeBlock = isolationProfile.slice(failsafeStart, failsafeEnd);
expect(failsafeBlock).toContain('<forkCount>1</forkCount>');
expect(failsafeBlock).toContain(
'junit.jupiter.extensions.autodetection.enabled = true',
);
expect(failsafeBlock).toContain(
'junit.jupiter.execution.parallel.enabled = false',
);
expect(failsafeBlock).toContain('qwen.hosted.mysql.isolation = true');
expect(pom.match(/qwen\.hosted\.mysql\.isolation = true/g)).toHaveLength(1);
expect(
read(
'packages/managed-agent-server/verification/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension',
).trim(),
).toBe('com.alibaba.qwen.code.managedagent.HostedMySqlIsolationExtension');
// The non-Hosted MySQL job must not narrow its selection either, or an IT outside
// the Hosted family would silently run nowhere.
const mysqlRun = java.jobs['mysql-integration'].steps.find(
(step) =>
step.name ===
'Install and verify Managed Agent dependencies, tests, Checkstyle, and MySQL integration',
);
expect(mysqlRun.run).toContain('-Pmysql-integration');
expect(mysqlRun.run).not.toContain('-Dit.test');
// The H2 unit suite belongs to the non-Hosted profile: the Hosted job's
// three Maven steps would each repeat it. Both engine legs run it, so this
// lane pays that suite twice — the accepted cost of keeping MariaDB covered.
const profile = (id) =>
pom.split(`<id>${id}</id>`)[1].split('</profile>')[0];
const skipsUnits =
/<artifactId>maven-surefire-plugin<\/artifactId>\s*<configuration>\s*<skipTests>true<\/skipTests>\s*<\/configuration>/;
for (const id of [
'hosted-harness-mysql',
'hosted-harness-gates',
'o4-mysql-gates',
]) {
expect(profile(id), id).toMatch(skipsUnits);
}
expect(profile('mysql-integration')).not.toContain('maven-surefire-plugin');
const mysqlProfile = profile('mysql-integration');
expect(mysqlProfile).toContain(
'<mysql.integration.forks>1</mysql.integration.forks>',
);
const mysqlFailsafe = mysqlProfile
.split('<artifactId>maven-failsafe-plugin</artifactId>')[1]
.split('</plugin>')[0];
expect(mysqlFailsafe).toContain(
'<forkCount>${mysql.integration.forks}</forkCount>',
);
expect(mysqlFailsafe).toContain('<reuseForks>true</reuseForks>');
expect(mysqlFailsafe).toContain(
'junit.jupiter.execution.parallel.enabled = false',
);
const routingProperties = mysqlFailsafe
.split('<systemPropertyVariables>')[1]
.split('</systemPropertyVariables>')[0];
expect(routingProperties).toContain(
'<qwen.mysql.forkCount>${mysql.integration.forks}</qwen.mysql.forkCount>',
);
expect(routingProperties).toContain(
'<qwen.mysql.forkNumber>0${surefire.forkNumber}</qwen.mysql.forkNumber>',
);
expect(pom.match(/<qwen\.mysql\.forkCount>/g)).toHaveLength(1);
expect(pom.match(/<qwen\.mysql\.forkNumber>/g)).toHaveLength(1);
expect(
read(
'packages/managed-agent-server/verification/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener',
).trim(),
).toBe('com.alibaba.qwen.code.managedagent.MySqlForkRoutingListener');
expect(mysqlRun.run).not.toMatch(/-DforkCount\b/);
// The step stages the SDK with -DskipTests before the MySQL run; only
// the integration command itself must keep its tests.
const mysqlVerify = mysqlRun.run.slice(
mysqlRun.run.indexOf('-Pmysql-integration'),
);
expect(mysqlVerify).not.toMatch(/-D(skipTests|maven\.test\.skip)/);
// Those checks read only the command lines, so each job also compares
// the reports of its run with the source tree, right after its last
// integration-test step.
for (const [name, after, check] of [
[
'mysql-integration',
mysqlRun.name,
'non-hosted packages/managed-agent-server',
],
[
'hosted-harness-mysql',
run.name,
'hosted packages/managed-agent-server',
],
]) {
const steps = java.jobs[name].steps;
const index = steps.findIndex((step) => step.name === after) + 1;
expect(steps[index].run, name).toBe(
`node scripts/check-failsafe-reports.js ${check}`,
);
expect(steps[index].if, name).toBeUndefined();
expect(steps[index]['continue-on-error'], name).toBeUndefined();
}
// Keep the existing integration tests in their families: a renamed Hosted
// test would move to non-Hosted MySQL, a renamed non-Hosted test to the Hosted job.
const its = readdirSync(
new URL(
'../../packages/managed-agent-server/verification/src/test/java/',
import.meta.url,
),
{ recursive: true },
)
.map((file) => path.basename(String(file)))
.filter((file) => file.endsWith('IT.java'));
expect(its.filter((file) => /^Hosted.*IT\.java$/.test(file))).toContain(
'HostedHarnessMySqlIT.java',
);
expect(its.filter((file) => !/^Hosted.*IT\.java$/.test(file))).toContain(
'ManagedAgentMySqlIT.java',
);
expect(its.filter((file) => !/^Hosted.*IT\.java$/.test(file))).toContain(
'JdbcRuntimeBrokerMySqlIT.java',
);
const upload = job.steps.find(
(step) => step.name === 'Upload Hosted process reports',
);
expect(upload.if).toBe('always()');
expect(upload.with.path).toContain('failsafe-reports');
// The admission and O4 steps both run `clean verify` on the module, so
// an upload below either ships an empty artifact with only a warning.
for (const name of [
'Verify Hosted admission gates',
'Verify O4 filesystem process and capacity gates',
]) {
const cleaning = job.steps.findIndex((step) => step.name === name);
expect(job.steps[cleaning].run, name).toContain('clean verify');
expect(job.steps.indexOf(upload), name).toBeLessThan(cleaning);
}
// The FG7 lane moved its per-case evidence out of @TempDir into this
// module-relative directory precisely so an artifact can carry it; the
// writer and the upload path must not drift.
expect(upload.with.path).toContain(
'packages/managed-agent-server/verification/target/fg7-cases/',
);
// FG8 follows the same precedent: its per-case evidence lives in
// target/fg8-cases/.
expect(upload.with.path).toContain(
'packages/managed-agent-server/verification/target/fg8-cases/',
);
// FG9's session-relay fault gates keep theirs in target/fg9-cases/.
expect(upload.with.path).toContain(
'packages/managed-agent-server/verification/target/fg9-cases/',
);
// FG10 follows the same precedent: its per-case evidence lives in
// target/fg10-cases/.
expect(upload.with.path).toContain(
'packages/managed-agent-server/verification/target/fg10-cases/',
);
// FG11a's automation lost-reply evidence lives in target/fg11-cases/.
expect(upload.with.path).toContain(
'packages/managed-agent-server/verification/target/fg11-cases/',
);
// FG12a's MCP reply-loss evidence lives in target/fg12-cases/.
expect(upload.with.path).toContain(
'packages/managed-agent-server/verification/target/fg12-cases/',
);
// FG13e's Hook stream-gap evidence lives in target/fg13-cases/.
expect(upload.with.path).toContain(
'packages/managed-agent-server/verification/target/fg13-cases/',
);
// The writer side of the same path: the Java literal must stay under
// the directory the step uploads, or the artifact is silently empty.
expect(read(hostedTestPath('HostedHookStreamGapIT'))).toContain(
'"target/fg13-cases"',
);
// The runner writes the Step 3 gate evidence under .qwen/, a hidden
// path that upload-artifact drops silently without the flag. The arms
// that write it run in the failover job, so the upload lives there.
const evidence = java.jobs['hosted-failover-e2e'].steps.find(
(step) => step.name === 'Upload G3 Step 3 gate evidence',
);
expect(evidence.if).toBe('always()');
expect(evidence.with.path).toBe(
'.qwen/investigations/g3-step3-implementation/',
);
expect(evidence.with['include-hidden-files']).toBe(true);
expect(upload.with.path).not.toContain('.qwen/');
// The writer side of the same path: the runner's default report
// directory must stay under what the step uploads, or the artifact
// goes empty with only a warning.
expect(read('scripts/run-managed-agent-server-e2e.ts')).toContain(
`\`${evidence.with.path}packaged-\${verificationMode}-`,
);
});
it('pins the cgroup v2 delegation the FG8 monitor/run gates need', () => {
const job = java.jobs['hosted-harness-mysql'];
const delegate = job.steps.find(
(step) => step.name === 'Delegate a cgroup v2 root for the process gates',
);
const verify = job.steps.find(
(step) => step.name === 'Verify Hosted Java, Spring and MySQL processes',
);
expect(delegate, 'the cgroup delegation step').toBeDefined();
// Without the root the gates refuse admission and burn their full
// driver budget before red — so the delegation must land first.
expect(job.steps.indexOf(delegate)).toBeLessThan(job.steps.indexOf(verify));
expect(verify.env.QWEN_MANAGED_HOOK_CGROUP_ROOT).toBe('/sys/fs/cgroup/fg8');
// Create/export coupling: a path rename on only one side fails the gates
// as a missing root, never as a red pin.
expect(delegate.run).toContain('mkdir -p /sys/fs/cgroup/fg8');
expect(delegate.run).toContain('test -f /sys/fs/cgroup/fg8/cgroup.kill');
// The Verify shell must be born inside the subtree: a process outside
// it cannot migrate into fg8 — the common ancestor's cgroup.procs is
// root-owned (kernel delegation containment).
expect(verify.run).toContain('sudo tee /sys/fs/cgroup/fg8/cgroup.procs');
// The delegation step smoke-tests admission with a real child joining a
// unit — cgroup.kill existing alone proves nothing about migration.
expect(delegate.run).toContain('/sys/fs/cgroup/fg8/smoke/cgroup.procs');
});
it('sweeps every Hook cgroup subtree a Hosted IT allocates', () => {
const owners = hostedTestClasses().filter(({ file }) =>
read(file).includes('QWEN_MANAGED_HOOK_CGROUP_ROOT'),
);
expect(owners.map(({ name }) => name)).toEqual(
expect.arrayContaining(['HostedExtensionsIT', 'HostedHookStreamGapIT']),
);
for (const { name, file } of owners) {
const source = javaCode(read(file));
expect(source, name).toContain('Files.walk(');
expect(source, name).toContain('hookRoot');
}
});
it.each([
// Step ceilings today (205 + 20 + 50 + 20): Verify, the Broker fault
// gates, admission and O4. The failover arms moved to their own job.
// The MySQL job's ceiling carries 33 minutes of setup allowance, above
// the 13-minute floor: a Verify that timed out used to skip admission,
// and now that path too can fill all four ceilings, so a slow setup
// must still leave the O4 gates time to run.
['hosted-harness-mysql', 295, 328, 33],
// Twenty-seven failover arms at 10 each; the allowance is ~13 minutes of
// setup plus 2.
['hosted-failover-e2e', 270, 285, 15],
])(
'keeps the %s job ceiling above its summed step ceilings',
(name, steps, ceiling, allowance) => {
const job = java.jobs[name];
const summed = job.steps.reduce(
(total, step) => total + (step['timeout-minutes'] ?? 0),
0,
);
// The uncapped setup steps need their own allowance of at least ~13
// minutes, so the enforced floor moves with that estimate, not the
// older 10. A ceiling may grant more (the MySQL job does, for the
// red-verify worst case).
expect(summed).toBe(steps);
expect(job['timeout-minutes']).toBeGreaterThanOrEqual(summed + 13);
expect(job['timeout-minutes']).toBe(ceiling);
// Raising a step ceiling must re-derive the allowance rather than
// quietly spend it.
expect(ceiling - steps).toBe(allowance);
},
);
it('runs the failover arms in their own job, apart from the Hosted MySQL verify', () => {
const hosted = java.jobs['hosted-harness-mysql'];
const job = java.jobs['hosted-failover-e2e'];
// A failing integration test in the Hosted MySQL job must not skip an
// arm, so no arm, MySQL binaries install or runner-only env stays there,
// and the failover job neither waits for it nor shares its services.
expect(
hosted.steps.filter((step) => / E2E$/.test(step.name ?? '')),
).toEqual([]);
expect(hosted.env?.QWEN_E2E_REGISTER_STORAGE).toBeUndefined();
expect(job.needs).toBeUndefined();
// Its only condition is the fast-tier skip the Hosted MySQL job has.
expect(job.if).toBe("${{ inputs.tier != 'fast' }}");
expect(job.services).toBeUndefined();
expect(job['runs-on']).toBe('ubuntu-latest');
expect(job.env.QWEN_E2E_REGISTER_STORAGE).toBe('1');
// Every arm the job runs has a row above, so none runs unpinned.
expect(
job.steps
.filter((step) => /^Run .* E2E$/.test(step.name ?? ''))
.map((step) => step.name)
.sort(),
).toEqual(failoverArms.map(([name]) => name).sort());
// The arms run the bundle and the packaged jar the job builds itself.
const index = (pattern) =>
job.steps.findIndex((step) => pattern.test(step.run ?? ''));
const bundle = index(/corepack pnpm install --frozen-lockfile/);
const sdk = index(/-f packages\/sdk-java\/qwencode\/pom\.xml .* install$/);
const pack = index(
/-f packages\/managed-agent-server\/pom\.xml\s+-DskipTests package$/,
);
const install = job.steps.findIndex(
(step) => step.name === 'Install MySQL binaries for the failover E2E',
);
expect(bundle).toBeGreaterThan(-1);
expect(job.steps[bundle].run).not.toContain('--ignore-scripts');
expect(sdk).toBeGreaterThan(bundle);
expect(pack).toBeGreaterThan(sdk);
expect(install).toBeGreaterThan(pack);
for (const step of [bundle, sdk, pack, install]) {
expect(job.steps[step].if).toBeUndefined();
expect(job.steps[step]['continue-on-error']).toBeUndefined();
}
expect(job.steps[install].id).toBe('failover_prerequisites');
});
it('runs every failover arm with its own runner arguments', () => {
const commands = failoverArms.map(([, script]) => pkg.scripts[script]);
expect(new Set(commands).size).toBe(failoverArms.length);
expect(new Set(failoverArms.map(([name]) => name)).size).toBe(
failoverArms.length,
);
});
it.each(failoverArms)(
'pins the %s arm into the Hosted failover job',
(stepName, script, flags) => {
const job = java.jobs['hosted-failover-e2e'];
const install = job.steps.find(
(step) => step.name === 'Install MySQL binaries for the failover E2E',
);
const step = job.steps.find((s) => s.name === stepName);
expect(step, stepName).toBeDefined();
expect(install, 'the MySQL binaries install step').toBeDefined();
expect(
job.steps.indexOf(step),
`${stepName} must run after the MySQL binaries install`,
).toBeGreaterThan(job.steps.indexOf(install));
expect(step.run).toContain(`npm run ${script}`);
expect(step['timeout-minutes'], stepName).toBe(10);
// An earlier arm's failure must not skip this one; a failed setup
// step still does, through the install step's outcome.
expect(step.if, stepName).toBe(
"${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}",
);
expect(step['continue-on-error'], stepName).toBeUndefined();
// A renamed or deleted npm script would leave the step failing for
// the wrong reason; pin that it drives the failover runner — with
// the flags that make each row a different arm.
expect(pkg.scripts[script], script).toContain(
'run-managed-agent-server-e2e',
);
for (const flag of flags) {
expect(pkg.scripts[script], `${script} carries ${flag}`).toContain(
flag,
);
}
// Each row must also be the only row with its mode flag, or two CI
// steps silently run the same arm. A valued flag ('--g3-verify allow')
// carries the flag name, and its value tells the rows apart.
for (const flag of [
'--session-failover',
'--inflight-failover',
'--continuation-failover',
'--model-round-failover',
'--harness-only',
'--freeze',
'--frozen-control-plane',
'--g3-verify',
'--big-output',
]) {
if (!flags.some((entry) => entry.split(' ')[0] === flag)) {
expect(pkg.scripts[script], `${script} omits ${flag}`).not.toContain(
flag,
);
}
}
},
);
it.each([
['Verify Hosted Java, Spring and MySQL processes', 'hosted-harness-mysql'],
['Verify O4 filesystem process and capacity gates', 'o4-mysql-gates'],
['Run Runtime Broker fault gates', 'fault-gates'],
['Verify Hosted admission gates', 'hosted-harness-gates'],
])(
'keeps the %s step ceiling above its fork timeout',
(stepName, profile) => {
const run = java.jobs['hosted-harness-mysql'].steps.find(
(step) => step.name === stepName,
);
// Tie the asserted ceiling to the profile the step actually runs: a
// re-pointed -P flag must not leave the row reading the old profile.
expect(run.run).toContain(`-P${profile}`);
const forkSeconds = Number(
read('packages/managed-agent-server/verification/pom.xml')
.split(`<id>${profile}</id>`)[1]
.match(/<forkedProcessTimeoutInSeconds>(\d+)</)[1],
);
// A step killed before its fork leaves no per-test failure lines, so
// the main-CI failure analyzer can only file an undiagnosable
// per-commit issue (#13503 for the Hosted verify step, #13684 for the
// O4 gates). Reserve 600 s beyond the fork for compilation and report
// teardown, plus spotbugs/checkstyle on the verify steps, so a step
// trim or fork bump reddens this row.
expect(run['timeout-minutes'] * 60).toBeGreaterThanOrEqual(
forkSeconds + 600,
);
// The hosted fork bumps were deliberate (FG7a,b,d–f plus the FG7c
// crash class, ~150 s of wall clock at 2x headroom; then the L4
// recovery class's four real-process cuts, ~300 s more the FG7
// sizing never covered — #14125's per-commit kill; the FG8
// monitor/run letters grew to five letters / twenty-one cases in
// #14334, whose lane (run 38848525079 at fee23d2d3c) saw four
// letters finish by ~700 s into the fork while the old 1800 s
// budget killed the fork before the last letter reported, so the
// fork doubles to 3600 in the same diff; the FG9 session/relay
// letters, ~170 s measured locally for slice 1 and ~314 s for the
// class with slice 2, fit inside that same lane-measured envelope;
// and FG9 slice 3's store-failure and crash letters bring the class
// to ~557 s, which with FG8c and FG10a inside the healthy allowance
// no longer leaves one 600 s ceiling room in 3600, so the fork moves
// to 3990 and the Verify step to 80 minutes; the FG10b/FG10f
// workflow relay letters add ~82 s, so the fork moves to 4155 and
// the Verify step to 85 minutes; their slice 2 (FG10c/FG10e) brings
// the class to ~182 s, so the fork moves to 4353 and the Verify step
// to 90 minutes; slice 3 (FG10d) brings it to ~247 s, so the fork
// moves to 4484, still inside those 90 minutes; the FG12a MCP
// reply-loss letter adds ~15 s, so the fork moves to 4499; the FG12c
// restart matrix (five cases) adds ~49 s and the FG13c Hook
// owner-restart cells add 300 s, so the fork moves to 4848 and the
// Verify step stays at 95 minutes; the FG13d cancel/timeout cells add
// two cases budgeted at 200 s, so the fork moves to 5048; the FG11a
// automation lost-reply letter adds ~170 s, so the fork moves to 5218
// and the Verify step to 100 minutes; the FG13d worker-stop cancel
// cell adds 150 s, so the fork moves to 5368; FG11b's store faults add
// ~130 s, so the fork moves to 5498 and the Verify step to 105
// minutes; FG11b slice 2's Harness-side store faults add ~145 s, so
// the fork moves to 5643, still inside those 105 minutes; the
// event-stream liveness gates add ~440 s, so the fork moves to 6083;
// the FG13d dropped-reply cancel cell adds 150 s, so the fork moves to
// 6233; the FG12b store-failure class adds ~120 s (49.6 s measured
// locally on JDK 21 + MySQL 8.0.46), so the fork moves to 6353 and the
// Verify step to 116 minutes; the FG12f stream-gap class adds ~242 s
// (121.1 s measured on the merged base), so the fork moves to 6585;
// the FG13a execute lost-reply cell adds 150 s, so the fork moves to
// 6735; the FG13a status lost-reply cell adds 150 s, so the fork moves
// to 6885 and the Verify step to 125 minutes; the FG11c Harness-crash
// method (144.8 s measured, 550 s budgeted) moves the fork to 7435;
// the FG13d DELETE-drain cell adds 150 s, so the fork moves to 7585;
// the FG11c Spring-crash class (262.0 s measured, ~524 s budgeted at
// 2x) moves the fork to 8109; the FG13a catalog lost-reply cell adds
// 150 s, so the fork moves to 8259 and the Verify step to 148 minutes;
// the FG13a HTTP lost-reply cell adds 150 s, so the fork moves to 8409
// and the Verify step to 151 minutes; the FG13f deny lost-reply cell
// adds 150 s, so the fork moves to 8559 and the Verify step to 153
// minutes; the FG11d disabled-span cell adds 336 s, so the fork moves
// to 8895 and the Verify step to 159 minutes; the FG13f failClosed
// lost-reply cell adds 150 s, so the fork moves to 9045 and the Verify
// step to 161 minutes; the FG12d refused-release cell adds 6 s, so the
// fork moves to 9051 and the Verify step stays 161 minutes; cleanup
// retention adds 60 s, moving the fork to 9111 and the Verify step to
// 162 minutes; the FG13b Hook intent store-rejection cell adds 150 s,
// so the fork moves to 9261 and the Verify step to 165 minutes; the
// FG13b Hook dispatch store-rejection cell adds 150 s, so the fork
// moves to 9411 and the Verify step to 167 minutes; the FG11f publication
// cell adds 82 s (2x its measured 41.1 s), so the fork moves to 9493 and
// the step to 169 minutes; FG12f result and catalog delivery add 30 s,
// and the FG11f refusing arms add 242 s, moving the fork to 9765 and
// the step to 173 minutes. Hook result and plan-result store rejection
// add 300 s, moving the fork to 10065 and the step to 178 minutes.
// The FG11d cancel cell adds 15 s, moving the fork to 10080 and
// the step to 179 minutes. The FG12f delivery allowance adds 20 s,
// moving the fork to 10100. The FG11d retire-at-intent cell adds 18 s,
// moving the fork to 10118. Cleanup adds 60 s, moving the fork to
// 10178 and the step to 180 minutes. The FG13e Hook stream-gap cell
// adds 150 s, moving the fork to 10328 and the step to 183 minutes;
// the FG11d close-queued cell adds 134 s (2x its measured 67.2 s),
// moving the fork to 10462 and the step to 185 minutes. The FG11d
// close-in-flight refusal cell adds 9 s (2x its measured 4.5 s), so the
// fork moves to 10471 and the step stays 185 minutes; the FG13f
// MessageDisplay reload cell adds 150 s, moving the fork to 10621 and
// the step to 188 minutes; the FG12d cancel-before-dispatch cell adds
// 18 s (2x its measured 9.1 s), so the fork moves to 10639; the FG11d
// retry-reuse cell adds 11 s (2x its measured 5.3 s), moving the fork to
// 10650 and the step stays 188 minutes; the FG12d Spring-restart
// owner-release cell adds 24 s (2x its measured 11.9 s), moving the fork
// to 10674; the FG11d settled-outcome cell adds 9 s (2x its measured
// 4.5 s), moving the fork to 10683; the FG13a result store ack-loss
// cell adds 150 s, moving the fork to 10833 and the step to 191
// minutes; the FG13a dispatch ack-loss cell adds 150 s, moving the fork
// to 10983 and the step to 194 minutes; the FG13a registration-settle
// ack-loss cell adds 150 s, moving the fork to 11133 and the step to
// 196 minutes; the FG11e stream cells add 42 s (2x their measured
// 14.1 s and 6.1 s), moving the fork to 11175; the FG11d cancel-retry cell adds 12 s (2x its
// measured 5.8 s), moving the fork to 11187; the FG11d second-fire refusal
// cell adds 25 s (2x its measured 12.3 s), moving the fork to 11212; the FG11d
// cancel-isolation cell adds 11 s (2x its measured 5.1 s), moving the fork to
// 11223 and the step to 198 minutes; the FG11d queue-one cell adds 13 s
// (2x its measured 6.5 s), moving the fork to 11236 and leaving the step at
// 198 minutes; the FG12e Runtime connection replacement cell adds 28 s
// (2x its measured 13.8 s), moving the fork to 11264 and leaving the step
// at 198 minutes). The pool fixture provisionally adds 180 s (its
// 120 s method ceiling plus 60 s for process/context cleanup), moving
// the fork to 11444; the FG9e cursor-expired-resync case adds 60 s
// (21–23 s measured), moving the fork to 11504 and the step to 202
// minutes; the FG12d two-server release cell adds 32 s (2x its
// measured 16.2 s), moving the fork to 11536 and the step to 203
// minutes; the FG12d detach-unknown cell adds 55 s (2x its measured
// 27.4 s), moving the fork to 11591 and the step to 204 minutes; the
// FG12d stopped-worker close cell adds 67 s (2x its measured 33.1 s),
// moving the fork to 11658 and the step to 205 minutes until
// CI measures it: the relational check above tolerates anything up to
// step*60, so the value is pinned literally here — an unnoticed raise
// has no other red to land on.
if (profile === 'hosted-harness-mysql') {
expect(forkSeconds).toBe(11658);
}
},
);
it.each([
// Without a condition a red Hosted verify skipped them on 10 of the 14
// nights to 10-27, though they need neither its build nor MySQL.
'Run Runtime Broker fault gates',
// The same held for these, which rebuild the module and need the MySQL
// service but not the verify's database.
'Verify Hosted admission gates',
// O4 has carried the condition since it was added, with nothing
// pinning it.
'Verify O4 filesystem process and capacity gates',
])('runs the %s step even when the Hosted verify fails', (name) => {
const steps = java.jobs['hosted-harness-mysql'].steps;
const gates = steps.find((step) => step.name === name);
expect(gates, name).toBeDefined();
expect(gates.if).toBe(
"${{ !cancelled() && steps.managed_dependencies.outcome == 'success' }}",
);
expect(gates['continue-on-error'], name).toBeUndefined();
// An unknown step id reads as '' and would skip the gates on every run.
const install = steps.find((step) => step.id === 'managed_dependencies');
expect(install, 'the managed_dependencies step').toBeDefined();
expect(install.name).toBe('Install Managed Agent dependencies');
expect(install.run).toContain('-f packages/sdk-java/qwencode/pom.xml');
// The install step runs only when no step before it failed, and a step
// skipped by its own `if:`, or one whose failure `continue-on-error`
// absorbs, fails nothing: either on the bundle step would leave the
// fault and admission gates spawning a missing dist/cli.js. So no step
// up to the install may carry either key.
// Name the offenders in the message: vitest's failure headline
// abbreviates an array whose string runs long to `[ Array(1) ]`, and
// the install step's own name is long enough to trip that.
const setup = steps.slice(0, steps.indexOf(install) + 1);
const conditioned = setup
.filter((step) => 'if' in step || 'continue-on-error' in step)
.map((step) => step.name ?? step.id ?? step.uses);
expect(
conditioned,
`setup steps with if: or continue-on-error: ${conditioned.join(', ')}`,
).toEqual([]);
const bundle = steps.findIndex(
(step) => step.name === 'Install, build and bundle CLI',
);
expect(bundle).toBeGreaterThan(-1);
expect(bundle).toBeLessThan(steps.indexOf(install));
expect(steps.indexOf(install)).toBeLessThan(steps.indexOf(gates));
// After the verify, a red verify would skip the install, and with it
// the gates, on exactly the nights this condition is for.
const verify = steps.findIndex(
(step) => step.name === 'Verify Hosted Java, Spring and MySQL processes',
);
expect(verify).toBeGreaterThan(-1);
expect(steps.indexOf(install)).toBeLessThan(verify);
});
it('keeps the steps after the Hosted verify off its outcome, bar its own checks', () => {
const steps = java.jobs['hosted-harness-mysql'].steps;
const verify = steps.findIndex(
(step) => step.name === 'Verify Hosted Java, Spring and MySQL processes',
);
expect(verify, 'the Hosted verify step').toBeGreaterThan(-1);
const after = steps.slice(verify + 1);
const label = (step) => step.name ?? step.id ?? step.uses;
// A step with no `if:`, or one without a status function (GitHub
// prefixes it with `success() &&`), runs only on a green job, so a red
// verify skips it, as it skipped the admission gates on all 13 red
// nights from 10-14 to 10-30. Exactly the two checks that need the verify's output (its
// failsafe reports, its latency baseline) must do that. Every other
// step, however it runs (Maven, a script, an action), takes one of
// these spellings: `always()`, or `${{ … }}` around `always()`,
// `!cancelled()` or `failure()`, optionally narrowed by one step's
// outcome. A condition that still reads the job status (`success()`,
// `job.status`, a negated `failure()`) can't pass, and the narrowing
// step's outcome must not depend on the verify: it runs before the
// verify, or after it under one of these conditions other than
// `failure()`. Static checks can't see what that step reads, only when
// it runs. None carries continue-on-error.
const runsAfterRedVerify =
/^(always\(\)|\$\{\{ (always\(\)|!cancelled\(\)|failure\(\))( && steps\.([\w-]+)\.outcome [!=]= '\w+')? \}\})$/;
const riding = after
.filter((step) => !runsAfterRedVerify.test(String(step.if ?? '')))
.map(label);
expect(
riding,
`steps outside the accepted conditions: ${riding.join(', ')}`,
).toEqual([
'Check that every Hosted integration test class ran',
'Check Hosted latency measurements',
]);
const running = new Set(
steps
.slice(0, verify)
.map((step) => step.id)
.filter(Boolean),
);
for (const step of after) {
const match = runsAfterRedVerify.exec(String(step.if ?? ''));
if (!match) continue;
const narrowing = match[4];
if (narrowing) {
expect(
running.has(narrowing),
`${label(step)} narrows on ${narrowing}, whose outcome may depend on the verify`,
).toBe(true);
}
// A failure() step runs only when something failed, so its outcome
// still follows the verify and can't anchor another step.
if (step.id && match[2] !== 'failure()') running.add(step.id);
}
const absorbing = after
.filter((step) => 'continue-on-error' in step)
.map(label);
expect(
absorbing,
`steps with continue-on-error: ${absorbing.join(', ')}`,
).toEqual([]);
// The Maven gates, derived from the job rather than listed so a gate
// added later joins the contract, take this exact condition. Maven may
// sit anywhere in the command (after `&&`, in a loop, behind `timeout`,
// an env assignment or a quoted path), so match the word.
const gates = after.filter((step) =>
/(^|[^\w.-])mvn[wd]?(?![\w.-])/.test(step.run ?? ''),
);
expect(gates.map(label)).toEqual(
expect.arrayContaining([
'Run Runtime Broker fault gates',
'Verify Hosted admission gates',
'Verify O4 filesystem process and capacity gates',
]),
);
for (const step of gates) {
expect(step.if, label(step)).toBe(
"${{ !cancelled() && steps.managed_dependencies.outcome == 'success' }}",
);
// A ceiling enters the summed step budget above, so a new gate has to
// re-derive the setup allowance instead of quietly spending it.
expect(step['timeout-minutes'] ?? 0, label(step)).toBeGreaterThan(0);
}
});
it('checks the admission gate classes whenever the admission step passes', () => {
const steps = java.jobs['hosted-harness-mysql'].steps;
const admission = steps.find(
(step) => step.name === 'Verify Hosted admission gates',
);
expect(admission, 'the admission step').toBeDefined();
expect(admission.id).toBe('hosted_admission');
const check = steps.find(
(step) =>
step.name === 'Check that every Hosted admission gate class ran',
);
expect(check, 'the admission class check').toBeDefined();
// Without its own condition the check runs only on a green job, so
// after a red Hosted verify it would skip while the admission step it
// audits ran: a class silently dropped from the profile would pass.
expect(check.if).toBe(
"${{ !cancelled() && steps.hosted_admission.outcome == 'success' }}",
);
// The check must still be able to fail the job: continue-on-error, an
// `|| true` on the command or a custom shell could turn a dropped gate
// class into a pass, the silent drift it exists to catch.
expect(check.run).toBe(
'node scripts/check-failsafe-reports.js hosted-gates packages/managed-agent-server',
);
expect(check['continue-on-error'], check.name).toBeUndefined();
expect(check.shell, check.name).toBeUndefined();
expect(steps.indexOf(check)).toBe(steps.indexOf(admission) + 1);
});
it('boots the admission gates on a database of their own', () => {
const job = java.jobs['hosted-harness-mysql'];
const admission = job.steps.find(
(step) => step.name === 'Verify Hosted admission gates',
);
expect(admission, 'the admission step').toBeDefined();
// Managed schema validation refuses any table outside the managed set,
// so an fg*_fire table a killed Hosted IT left in hosted_harness_test
// would fail both gates at boot, on the red nights they now run on.
// The command, its env and its shell are pinned whole. An `||`
// fallback, a pipe, or -fn in the command or MAVEN_ARGS could hide a
// failure the class check misses, since it counts a failed class as
// one that ran; a second -Dmysql.url would point it back at the shared
// database. The default shell's -e stops it when CREATE DATABASE fails.
expect(admission.run).toBe(
[
`mysql --no-defaults --protocol=TCP --host=127.0.0.1 --port="\${MYSQL_PORT}" --user=root -e 'CREATE DATABASE qwen_admission_ci'`,
'mvn --batch-mode --no-transfer-progress -f packages/managed-agent-server/pom.xml \\',
' -Phosted-harness-gates \\',
' -Dnode.executable="$(command -v node)" \\',
' -Dqwen.cli.entry="${GITHUB_WORKSPACE}/dist/cli.js" \\',
' -Dmysql.url="jdbc:mysql://127.0.0.1:${MYSQL_PORT}/qwen_admission_ci?allowPublicKeyRetrieval=true&useSSL=false" \\',
' -Dmysql.user=root -Dmysql.password=hosted-fixture clean verify checkstyle:check',
].join('\n'),
);
expect(admission.shell, admission.name).toBeUndefined();
expect(admission.env).toEqual({
MAVEN_ARGS:
'--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml',
MYSQL_PORT: "${{ job.services.mysql.ports['3306'] }}",
MYSQL_PWD: 'hosted-fixture',
});
expect(job.services.mysql.env.MYSQL_ROOT_PASSWORD).toBe('hosted-fixture');
});
// The Hosted*IT classes share one failsafe fork, so a case stalled to its
// @Timeout must still leave the fork its healthy runtime: on main the
// classes measured 437–546s with HostedTeamLeadMySqlIT adding ~65s, the
// FG8 monitor/run letters grew from two letters / seven cases to five
// letters / twenty-one cases in #14334 — on that PR's lane (run
// 38848525079 at fee23d2d3c) four letters finished by ~700s into the
// fork, the fork was still killed at the old 1800s budget before the last
// letter reported, and the fork now sits at 3600s for the same 2×
// headroom — and the FG9 session/relay letters, six since slice 3,
// measure 528–557s for the class locally on JDK 21 + MySQL 8.4 across
// three acceptance runs (~950s budgeted at >1.7x the heaviest measured
// run, up from slice 2's ~534s; the class ran 310s on lane run
// 38957583765 with four letters, as locally) in the same envelope, the
// FG8c process-crash letter adds 144s measured locally on JDK 21 +
// MySQL 8.4.6 (~290s budgeted at 2x), and the FG10a workflow digest
// letter — four cases since #14390 — measures 15.9–16.2s for the letter
// locally on macOS JDK 26 + MySQL 8.4.6 across four acceptance runs
// (~33s budgeted at 2x), and the FG10b–f workflow relay letters
// (HostedWorkflowRelayFaultGatesIT, five letters since slice 3's FG10d)
// measure 246.1–246.8s for the class locally on JDK 21 + MySQL 8.4.6
// across three acceptance runs (~494s budgeted at 2x, as FG10a, up from
// slice 2's ~363s); with the fork itself ~14s, 3874s (3545s after slice
// 1, plus slice 2's ~198s and slice 3's ~131s) covers them; FG12a's
// ~15s budgeted at 2x raises it to 3889s. On the lane (run 39087108383
// at a029896eff, FG10d's head, before FG12a) the nineteen classes then
// in the fork ran 3085s from the first class's start to the last
// class's end. FG12c's five-case restart matrix measures ~49s healthy
// locally (~98s budgeted at 2x); the FG13c Hook owner-restart cells
// (issue #14449, three cases) are budgeted at 300s until the lane
// measures them. The healthy sum becomes 4238s; the FG13d cancel/timeout
// cells (issue #14449, two cases) add 200s, so it becomes 4438s. The FG11a
// automation lost-reply letter (HostedAutomationFaultGatesIT, eight cases
// sharing one minute boundary) measures 46.3-85.0s locally on JDK 21 +
// MySQL 8.4.6 across four acceptance runs (~170s budgeted at 2x), so it
// becomes 4608s; the FG13d worker-stop cancel cell adds 150s, so it
// becomes 4758s. FG11b's Spring-side store faults, a second method of the
// automation class (six cases sharing one minute boundary), measure
// 30.8-64.5s across three acceptance runs (~130s budgeted at 2x), so it
// becomes 4888s. FG11b slice 2's Harness-side store faults, a third method
// of the class (four cases, three cold restarts), measure 62.1-72.4s across
// three acceptance runs (~145s budgeted at 2x), so it becomes 5033s. The
// event-stream liveness gates (HostedEventStreamLivenessIT, four cases on
// the shipped 15s keepalive and 45s bound) measure 214.4–215.0s for the
// class locally on JDK 21 and 25 + MySQL 8.4.6 across four acceptance runs
// (~440s budgeted at 2x), so it becomes 5473s; the FG13d dropped-reply
// cancel cell adds 150s, so it becomes 5623s; the FG12b store-failure
// class adds ~120s (49.6s measured on JDK 21 + MySQL 8.0.46), so it
// becomes 5743s; the FG12f stream-gap class (HostedMcpStreamGapIT, three
// cases, three cold boots) measures 121.1s locally on the merged base
// (~242s budgeted), so it becomes 5985s; the FG13a execute lost-reply
// cell adds 150s, so it becomes 6135s; the FG13a status lost-reply cell
// adds 150s, so it becomes 6285s; the FG11c Harness-crash method (144.8s
// measured, ~550s budgeted at 2x) becomes 6835s; the FG13d DELETE-drain
// cell adds 150s, so it becomes 6985s; the FG11c Spring-crash class
// (262.0s measured, ~524s budgeted at 2x) becomes 7509s; the FG13a
// catalog lost-reply cell adds 150s, so it becomes 7659s; the FG13a HTTP
// lost-reply cell adds 150s, so it becomes 7809s; the FG13f deny lost-reply
// cell adds 150s, so it becomes 7959s; the FG11d disabled-span cell adds
// 336s (2x its measured 168.1s), so it becomes 8295s; the FG13f failClosed
// lost-reply cell adds 150s, so it becomes 8445s; the FG12d refused-release
// cell adds 6s, so it becomes 8451s. The cleanup-retention MySQL class
// measures 22.2s on JDK 21 + MySQL 8.4.11, budgeted at 60s, so it becomes
// 8511s; the FG13b Hook intent store-rejection cell adds 150s, so it
// becomes 8661s; the FG13b Hook dispatch store-rejection cell adds 150s,
// so it becomes 8811s; the FG11f publication cell adds 82s, so it becomes
// 8893s; the FG12f delivery cell adds 30s, so it becomes 8923s; the FG11f
// refusing-arms cells add 242s, so it becomes 9165s; the FG13b Hook result
// store-rejection cell adds 150s, so it becomes 9315s; the FG13b Hook
// plan-result store-rejection cell adds 150s, so it becomes 9465s; the FG11d
// cancel cell adds 15s (2x its measured 7.4s), so it becomes 9480s; the
// FG12f delivery entry moves from 30s to 50s at 2x (four cases, 24.16s
// measured), so it becomes 9500s; the FG11d retire-at-intent cell adds 18s
// (2x its measured 8.6s), so it becomes 9518s.
// Class-boundary cleanup adds 60s for 9578s; the FG13e Hook stream-gap
// cell adds 150s, so it becomes 9728s; the FG11d close-queued cell adds
// 134s (2x its measured 67.2s), so it becomes 9862s; the close-in-flight
// refusal cell adds 9s (2x its measured 4.5s), so it becomes 9871s; the
// FG13f MessageDisplay reload cell adds 150s, so it becomes 10021s; the
// FG12d cancel-before-dispatch cell adds 18s (2x its measured 9.1s), so it
// becomes 10039s; the retry-reuse cell adds 11s (2x its measured 5.3s), so it
// becomes 10050s; the FG12d Spring-restart owner-release cell adds 24s (2x
// its measured 11.9s), so it becomes 10074s; the settled-outcome cell adds
// 9s (2x its measured 4.5s), so it becomes 10083s; the FG13a result store
// ack-loss cell adds 150s, so it becomes 10233s; the FG13a dispatch
// ack-loss cell adds 150s, so it becomes 10383s; the FG13a registration-settle
// ack-loss cell adds 150s, so it becomes 10533s; the FG11e stream cells
// add 42s (2x their measured 14.1s and 6.1s), so it becomes 10575s; the cancel-retry cell adds 12s (2x its measured
// 5.8s), so it becomes 10587s; the second-fire refusal cell adds 25s (2x its
// measured 12.3s), so it becomes 10612s; the cancel-isolation cell adds 11s (2x its measured
// 5.1s), so it becomes 10623s; the queue-one cell adds 13s (2x its measured
// 6.5s), so it becomes 10636s; the FG12e Runtime connection replacement cell
// adds 28s (2x its measured 13.8s), so it becomes 10664s. The pool fixture
// provisionally reserves 180s (its 120s method ceiling plus 60s for
// process/context cleanup), so it becomes 10844s until CI measures it. The
// largest
// ceiling (600s) plus
// that healthy sum still fits the fork. A larger ceiling lets failsafe
// kill the fork mid-run, and the classes left unrun never report, failing
// the gate undiagnosably (#13780).
const hostedForkHealthySeconds = 10844;
const hostedForkSeconds = () =>
Number(
read('packages/managed-agent-server/verification/pom.xml')
.split('<id>hosted-harness-mysql</id>')[1]
.match(/<forkedProcessTimeoutInSeconds>(\d+)</)[1],
);
const hostedTestPath = (className) =>
'packages/managed-agent-server/verification/src/test/java/' +
`com/alibaba/qwen/code/managedagent/${className}.java`;
// Every class the fork's **/Hosted*IT.java include picks up, in any
// package.
const hostedTestClasses = () => {
const root = 'packages/managed-agent-server/verification/src/test/java/';
return readdirSync(new URL(`../../${root}`, import.meta.url), {
recursive: true,
})
.map(String)
.filter((file) => /^Hosted\w*IT\.java$/.test(path.basename(file)))
.map((file) => ({
name: path.basename(file, '.java'),
file: root + file.split(path.sep).join('/'),
}))
.sort((a, b) => a.name.localeCompare(b.name));
};
// Java code without its string literals and comments, so neither a
// commented-out annotation nor a "/*" inside a string skews the counts.
// One pass, so whichever of a literal or a comment opens first wins.
const javaCode = (source) =>
source.replace(
/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|\/\*[\s\S]*?\*\/|\/\/.*$/gm,
(token) =>
token.startsWith('"') ? '""' : token.startsWith("'") ? "''" : '',
);
const hostedTestSource = (className) =>
javaCode(read(hostedTestPath(className)));
const ceilingsOf = (source) =>
[...source.matchAll(/@(?:[\w.]+\.)?Timeout\((\d+)\)/g)].map((m) =>
Number(m[1]),
);
const casesOf = (source) =>
[
...source.matchAll(
/@(?:[\w.]+\.)?(Test|RepeatedTest|ParameterizedTest|TestFactory|TestTemplate)\b/g,
),
].length;
// A regression in these classes stalls every case at once; the summed
// rule below holds them.
const hostedSummedClasses = [
'HostedTeamLeadMySqlIT',
'HostedTeamMemberCommandMySqlIT',
];
it('budgets every Hosted*IT class the shared fork runs', () => {
// A new class adds its runtime to hostedForkHealthySeconds in the same
// diff; pinning the list puts that on a red here, not on a fork kill
// on main (#14148).
expect(hostedTestClasses().map(({ name }) => name)).toEqual([
'HostedAutomationFaultGatesIT',
'HostedAutomationSpringCrashIT',
'HostedChannelFaultGatesIT',
'HostedChannelProcessCrashIT',
'HostedCleanupRetentionMySqlIT',
'HostedCommittedEventLineReplayIT',
'HostedConcurrentTurnBurstMySqlIT',
'HostedEventStreamLivenessIT',
'HostedExtensionsIT',
'HostedHarnessMySqlIT',
'HostedHarnessPoolMySqlIT',
'HostedHookCancelFaultGatesIT',
'HostedHookFaultGatesIT',
'HostedHookMessageDisplayReloadIT',
'HostedHookReplyLossFaultGatesIT',
'HostedHookStreamGapIT',
'HostedMcpReplyLossIT',
'HostedMcpRestartFaultGatesIT',
'HostedMcpResultDeliveryIT',
'HostedMcpStoreFaultGatesIT',
'HostedMcpStreamGapIT',
'HostedMonitorRunFaultGatesIT',
'HostedMySqlIsolationIT',
'HostedProcessCrashIT',
'HostedPublicWorkspaceIT',
'HostedSessionRelayFaultGatesIT',
'HostedShellLifecycleRecoveryIT',
'HostedTeamLeadMySqlIT',
'HostedTeamMemberCommandMySqlIT',
'HostedWorkflowFaultGatesIT',
'HostedWorkflowRelayFaultGatesIT',
'HostedWorkspaceConcurrencyIT',
'HostedWorkspaceRecoveryWorkerIT',
'HostedWorkspaceStorageGuardMySqlIT',
'HostedWorkspaceToolTurnIT',
]);
});
it.each(
hostedTestClasses()
.filter(({ name }) => !hostedSummedClasses.includes(name))
.map(({ name, file }) => [name, file]),
)('keeps each %s ceiling inside the shared fork budget', (name, file) => {
// A stalled case fails alone and the fork runs on, so each ceiling
// must leave the fork its healthy runtime — the shape the FG7–FG10
// letters, independent fixtures sharing the fork, were first held
// to. A case with no ceiling can stall until failsafe kills the fork.
const source = javaCode(read(file));
const ceilings = ceilingsOf(source);
expect(casesOf(source), name).toBeGreaterThan(0);
expect(ceilings, name).toHaveLength(casesOf(source));
for (const ceiling of ceilings) {
expect(ceiling + hostedForkHealthySeconds, name).toBeLessThanOrEqual(
hostedForkSeconds(),
);
}
});
it.each(hostedSummedClasses)(
'keeps every %s ceiling together inside the shared fork budget',
(className) => {
const source = hostedTestSource(className);
const cases = casesOf(source);
const ceilings = ceilingsOf(source);
expect(cases).toBeGreaterThan(0);
expect(ceilings).toHaveLength(cases);
// A regression such as a lost wake Turn or a stalled message relay
// leaves no failed row for the cases to stop on, so every case may
// stall to its ceiling at once.
const total = ceilings.reduce((sum, ceiling) => sum + ceiling, 0);
expect(total + hostedForkHealthySeconds).toBeLessThanOrEqual(
hostedForkSeconds(),
);
// Awaitility ignores the interrupt a @Timeout sends, so a ceiling
// holds only when every wait goes through the helper that fails on
// it.
expect([...source.matchAll(/\bawait\(\)/g)]).toHaveLength(1);
// A class may configure awaitility before chaining failFast — for
// example to ignore a deadlock a sibling delivery thread rolled back —
// so pin the guard itself, not the exact `await().failFast` prefix.
expect(read(hostedTestPath(className))).toContain(
'.failFast("The case\'s @Timeout interrupted it"',
);
},
);
// Awaitility replaces, rather than composes, the exception predicate each
// ignore* call installs. A helper that polls a child JVM's readiness must
// therefore keep ConnectException in the same predicate as the deadlock it
// newly ignores, or the first refused connection aborts the case.
it.each(['HostedExtensionsIT', 'HostedHookFaultGatesIT'])(
'keeps %s waiting through a refused connection and a deadlock',
(className) => {
const chain = hostedTestSource(className).match(
/await\(\)[\s\S]*?\.untilAsserted\(/,
);
expect(chain, className).not.toBeNull();
expect(chain[0], className).toContain('.ignoreExceptionsMatching(');
expect(chain[0], className).toContain(
'e instanceof java.net.ConnectException',
);
expect(chain[0], className).toContain(
'e instanceof CannotAcquireLockException',
);
},
);
// The Hosted*IT classes share one fork, and Spring caches a test context
// for the rest of it. A Managed Agent context's recovery scans cover
// every tenant of its schema, so one left open on the shared MySQL schema
// claims the work of the classes that run after it. A context inherited
// from a base class or a meta-annotation is not seen here; none exists.
it('closes the Spring test context each Hosted IT class declares', () => {
const contexts = hostedTestClasses()
.map(({ name, file }) => {
const code = javaCode(read(file));
const declaration = code.search(new RegExp(`\\bclass\\s+${name}\\b`));
expect(declaration, name).toBeGreaterThan(0);
// Only the annotations on the test class itself: the header starts
// where a type declared before it ends, a brace closed outside any
// parentheses (an annotation's array initializer sits inside them).
let start = 0;
for (let i = 0, braces = 0, parens = 0; i < declaration; i++) {
if (code[i] === '(') parens++;
else if (code[i] === ')') parens--;
else if (code[i] === '{') braces++;
else if (code[i] === '}' && --braces === 0 && parens === 0)
start = i + 1;
}
return { name, header: code.slice(start, declaration) };
})
.filter(({ header }) =>
/@\s*(?:[\w.]+\.)?(?:SpringBootTest|SpringJUnitConfig|SpringJUnitWebConfig|ContextConfiguration)\b/.test(
header,
),
);
expect(contexts.map(({ name }) => name)).toEqual(
expect.arrayContaining([
'HostedConcurrentTurnBurstMySqlIT',
'HostedWorkspaceRecoveryWorkerIT',
]),
);
for (const { name, header } of contexts) {
const dirties = header.match(
/@\s*(?:[\w.]+\.)?DirtiesContext\b(?:\s*\(([^)]*)\))?/,
);
expect(dirties, name).not.toBeNull();
// classMode defaults to AFTER_CLASS; AFTER_EACH_TEST_METHOD also
// closes the context before the next class starts.
const mode = (dirties[1] ?? '').match(
/\bclassMode\s*=\s*(?:[\w.]+\s*\.\s*)*(\w+)/,
);
if (mode !== null) {
expect(['AFTER_CLASS', 'AFTER_EACH_TEST_METHOD'], name).toContain(
mode[1],
);
}
}
});
});