mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Merge remote-tracking branch 'origin/split/13902-team-coordination' into split/13902-teammate-activity
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
commit
d73ad9e296
2183 files changed
+133432
-25273
No files matched your search
@@ -18,3 +18,11 @@ paths:
|
||||
# GitHub supports queue: max; actionlint 1.7.12 does not recognize it yet.
|
||||
# https://github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues/
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
# The release-runner preflight must wipe the workspace *before* actions/checkout,
|
||||
# so it cannot use a './' local action (those resolve against GITHUB_WORKSPACE and
|
||||
# need a prior checkout). GitHub's '$/' self-repository reference resolves at the
|
||||
# running commit with no checkout; actionlint 1.7.12 does not recognize the form.
|
||||
# https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions#adding-an-action-from-the-same-repository
|
||||
.github/workflows/**:
|
||||
ignore:
|
||||
- 'specifying action "\$/\.github/actions/prepare-release-runner" in invalid format because ref is missing'
|
||||
@@ -0,0 +1,27 @@
|
||||
name: 'Prepare isolated release runner'
|
||||
description: 'Wipes the workspace and isolates Git/npm/Docker/gh state before a release job checks out on a persistent self-hosted machine. No-op on GitHub-hosted runners.'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# This runs before actions/checkout, so the scripts are not on disk yet and
|
||||
# have to come from the API at the pinned workflow SHA.
|
||||
- name: 'Prepare isolated release runner'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
working-directory: '${{ runner.temp }}'
|
||||
env:
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
WORKFLOW_SHA: '${{ github.workflow_sha }}'
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
bootstrap=$(mktemp -d "${RUNNER_TEMP:?}/release-bootstrap.XXXXXX")
|
||||
trap 'rm -rf -- "$bootstrap"' EXIT
|
||||
export GH_CONFIG_DIR="$bootstrap/gh"
|
||||
mkdir -p "$GH_CONFIG_DIR"
|
||||
for script in prepare-release-runner.sh check-disk-floor.sh; do
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=${WORKFLOW_SHA}" --jq .content | base64 --decode > "$bootstrap/$script"
|
||||
done
|
||||
bash "$bootstrap/prepare-release-runner.sh"
|
||||
node -e 'if (Number(process.versions.node.split(".")[0]) !== 22) throw new Error("Release hosts require Node 22")'
|
||||
bash "$bootstrap/check-disk-floor.sh" "$GITHUB_WORKSPACE" "$RUNNER_TEMP"
|
||||
@@ -7,21 +7,17 @@
|
||||
// assigned. The diff's file paths are matched against the optional `paths`
|
||||
// list of each area in .github/issue-owners.json; the longest matching
|
||||
// prefix wins, so a module-level entry overrides the coarser fallback area
|
||||
// that contains it. The assignee is the area's least loaded eligible
|
||||
// owner, rotated by PR number on ties — the same load metric and rotation as
|
||||
// issue assignment. Push access is re-verified against the live collaborator
|
||||
// API before the write, coverage is re-checked against the live PR
|
||||
// that contains it. The assignee is sampled from the area's eligible owners
|
||||
// with weight 1 / (open issues + 1), so lower loads are more likely without
|
||||
// monopolizing assignments. Push access is re-verified against the live
|
||||
// collaborator API before the write, coverage is re-checked against the live PR
|
||||
// immediately before it, and the run no-ops once any mapped owner is already
|
||||
// an assignee or has reviewed, so repeated pushes never stack assignments.
|
||||
import { appendFileSync, readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
loadPolicy,
|
||||
openIssueCount,
|
||||
pickOwner,
|
||||
} from './assign-issue-owner.mjs';
|
||||
import { loadPolicy, openIssueCount } from './assign-issue-owner.mjs';
|
||||
|
||||
const OWNERS_FILE = '.github/issue-owners.json';
|
||||
const WRITE_PERMISSIONS = new Set(['admin', 'maintain', 'write']);
|
||||
@@ -80,6 +76,17 @@ export function matchAreaByPath(policy, files) {
|
||||
return matchedAreasByPath(policy, files)[0] ?? null;
|
||||
}
|
||||
|
||||
export function pickPrOwner(owners, loadByOwner, randomValue) {
|
||||
const weights = owners.map((owner) => 1 / (loadByOwner.get(owner) + 1));
|
||||
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
let remaining = randomValue * totalWeight;
|
||||
for (let index = 0; index < owners.length; index += 1) {
|
||||
remaining -= weights[index];
|
||||
if (remaining < 0) return owners[index];
|
||||
}
|
||||
return owners.at(-1);
|
||||
}
|
||||
|
||||
// An assignee or a non-dismissed review by any mapped owner means this
|
||||
// routing already happened; never stack a second assignment. A dismissed
|
||||
// review is a removed review, so it must not count as coverage.
|
||||
@@ -122,8 +129,8 @@ function canWrite(repository, login) {
|
||||
// `gh issue list` per eligible owner (up to 15 for the core pool), and one
|
||||
// transient failure — secondary rate limit, 5xx, issues disabled — must not
|
||||
// fail the run on the contributor's PR. Retry once, then degrade to a zero
|
||||
// load so the rotation still lands an owner; the load metric is a tie-break
|
||||
// heuristic, not a gate on assigning.
|
||||
// load so the weighted draw still lands an owner; the load metric biases
|
||||
// the probabilities rather than gating assignment.
|
||||
function ownerLoad(repository, login) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
@@ -136,7 +143,7 @@ function ownerLoad(repository, login) {
|
||||
console.warn(
|
||||
`::warning::Cannot read open-issue load for @${login}: ${lastError.message}`,
|
||||
);
|
||||
return 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// `gh pr view --json files` caps at 100 entries, so the REST files endpoint
|
||||
@@ -228,15 +235,23 @@ function main() {
|
||||
]);
|
||||
}
|
||||
|
||||
const unavailableLoads = [];
|
||||
const loadByOwner = new Map(
|
||||
eligible.map((owner) => [owner, ownerLoad(repository, owner)]),
|
||||
eligible.map((owner) => {
|
||||
const load = ownerLoad(repository, owner);
|
||||
if (load === undefined) unavailableLoads.push(owner);
|
||||
return [owner, load ?? 0];
|
||||
}),
|
||||
);
|
||||
const assignee = pickOwner(eligible, loadByOwner, prNumber);
|
||||
const draw = Math.random();
|
||||
const assignee = pickPrOwner(eligible, loadByOwner, draw);
|
||||
const selectionSummary = `Selection: draw ${draw}; loads ${eligible.map((owner) => `${owner}:${loadByOwner.get(owner)}`).join(' ')}; unavailable: ${unavailableLoads.join(', ') || 'none'}`;
|
||||
|
||||
if (dryRun) {
|
||||
record([
|
||||
`Area: ${area.name}`,
|
||||
`Assignment: dry-run — would assign @${assignee} (${loadByOwner.get(assignee)} open)`,
|
||||
selectionSummary,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -324,12 +339,14 @@ function main() {
|
||||
record([
|
||||
`Area: ${area.name}`,
|
||||
`Assignment: skipped — GitHub did not accept @${assignee} as an assignee`,
|
||||
selectionSummary,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
record([
|
||||
`Area: ${area.name}`,
|
||||
`Assignment: assigned @${assignee} (${loadByOwner.get(assignee)} open)`,
|
||||
selectionSummary,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,15 +15,16 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
import { loadPolicy, pickOwner } from './assign-issue-owner.mjs';
|
||||
import { loadPolicy } from './assign-issue-owner.mjs';
|
||||
import {
|
||||
alreadyCovered,
|
||||
changedFiles,
|
||||
matchAreaByPath,
|
||||
matchedAreasByPath,
|
||||
pickPrOwner,
|
||||
skipPrReason,
|
||||
} from './assign-pr-owner.mjs';
|
||||
|
||||
@@ -205,8 +206,51 @@ describe('assign-pr-owner: pure routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The stub reports the zeroLoadOwner as the least loaded owner so the pick is
|
||||
// unambiguous regardless of the rotation offset for PR 77.
|
||||
describe('assign-pr-owner: weighted selection', () => {
|
||||
it('uses inverse issue-count weights with reachable interval boundaries', () => {
|
||||
const owners = ['a', 'b', 'c'];
|
||||
const loads = new Map([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
['c', 1],
|
||||
]);
|
||||
for (const [draw, expected] of [
|
||||
[0, 'a'],
|
||||
[0.5 - Number.EPSILON, 'a'],
|
||||
[0.5, 'b'],
|
||||
[0.75 - Number.EPSILON, 'b'],
|
||||
[0.75, 'c'],
|
||||
[1 - Number.EPSILON, 'c'],
|
||||
]) {
|
||||
assert.equal(pickPrOwner(owners, loads, draw), expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives loads 0, 1, and 3 the independently calculated 4:2:1 ratio', () => {
|
||||
const owners = ['a', 'b', 'c'];
|
||||
const loads = new Map([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
['c', 3],
|
||||
]);
|
||||
const counts = { a: 0, b: 0, c: 0 };
|
||||
for (let index = 0; index < 700; index += 1) {
|
||||
counts[pickPrOwner(owners, loads, (index + 0.5) / 700)] += 1;
|
||||
}
|
||||
assert.deepEqual(counts, { a: 400, b: 200, c: 100 });
|
||||
});
|
||||
|
||||
it('gives equal loads equal chances and keeps a single owner selectable', () => {
|
||||
const owners = ['a', 'b'];
|
||||
const loads = new Map(owners.map((owner) => [owner, 100]));
|
||||
assert.equal(pickPrOwner(owners, loads, 0), 'a');
|
||||
assert.equal(pickPrOwner(owners, loads, 0.5), 'b');
|
||||
assert.equal(pickPrOwner(['a'], loads, 0), 'a');
|
||||
assert.equal(pickPrOwner(['a'], loads, 1 - Number.EPSILON), 'a');
|
||||
});
|
||||
});
|
||||
|
||||
// A test-only preload fixes the random draw for the real script entrypoint.
|
||||
function runAssign(dryRun, options = {}) {
|
||||
const {
|
||||
prJson = JSON.stringify(corePr),
|
||||
@@ -220,6 +264,7 @@ function runAssign(dryRun, options = {}) {
|
||||
fileList,
|
||||
previousFiles = [],
|
||||
zeroLoadOwner = 'DennisYu07',
|
||||
randomValue = 0.5,
|
||||
// What every collaborator permission lookup answers; 'error' fails the
|
||||
// lookup outright instead of answering.
|
||||
permission = 'write',
|
||||
@@ -244,6 +289,16 @@ function runAssign(dryRun, options = {}) {
|
||||
tempDirs.push(dir);
|
||||
const log = join(dir, 'gh.log');
|
||||
const gh = join(dir, 'gh');
|
||||
const randomPreload = join(dir, 'random.mjs');
|
||||
writeFileSync(
|
||||
randomPreload,
|
||||
`let draws = 0;
|
||||
Math.random = () => {
|
||||
if (++draws > 1) throw new Error('second Math.random draw');
|
||||
return ${randomValue};
|
||||
};
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
gh,
|
||||
`#!/bin/sh
|
||||
@@ -308,42 +363,46 @@ esac
|
||||
`,
|
||||
);
|
||||
chmodSync(gh, 0o755);
|
||||
const result = spawnSync(process.execPath, [script], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH}`,
|
||||
GH_STUB_LOG: log,
|
||||
GH_STUB_PR: prJson,
|
||||
GH_STUB_PR_LATEST: prLatestJson,
|
||||
// What the legacy text-rendering filter would print: one filename per
|
||||
// line, so an embedded newline forges extra entries.
|
||||
GH_STUB_FILES: fileNames.join('\n'),
|
||||
// What the fixed `| @base64` filter prints: one base64 token per
|
||||
// filename, so an embedded newline stays inside one entry.
|
||||
GH_STUB_FILES_B64: fileNames
|
||||
.map((name) => Buffer.from(name, 'utf8').toString('base64'))
|
||||
.join('\n'),
|
||||
GH_STUB_PREVIOUS_FILES_B64: previousFiles
|
||||
.map((name) => Buffer.from(name, 'utf8').toString('base64'))
|
||||
.join('\n'),
|
||||
GH_STUB_PERMISSION: permission,
|
||||
// The stub's deny branch pattern-expands this login; '__none__' can
|
||||
// never collide with a real collaborator lookup.
|
||||
GH_STUB_DENY_LOGIN: denyLogin || '__none__',
|
||||
GH_STUB_DENY_PERM: denyPerm,
|
||||
GH_STUB_EDIT_EXIT: String(editExit),
|
||||
GH_STUB_EDIT_ERR: editErr,
|
||||
GH_STUB_ASSIGN: assignResult,
|
||||
GH_STUB_LOAD_FAIL: loadFail,
|
||||
GH_STUB_LOAD_ERR: loadErr,
|
||||
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
||||
GITHUB_STEP_SUMMARY: '',
|
||||
PR_NUMBER: '77',
|
||||
DRY_RUN: String(dryRun),
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--import', pathToFileURL(randomPreload).href, script],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH}`,
|
||||
GH_STUB_LOG: log,
|
||||
GH_STUB_PR: prJson,
|
||||
GH_STUB_PR_LATEST: prLatestJson,
|
||||
// What the legacy text-rendering filter would print: one filename per
|
||||
// line, so an embedded newline forges extra entries.
|
||||
GH_STUB_FILES: fileNames.join('\n'),
|
||||
// What the fixed `| @base64` filter prints: one base64 token per
|
||||
// filename, so an embedded newline stays inside one entry.
|
||||
GH_STUB_FILES_B64: fileNames
|
||||
.map((name) => Buffer.from(name, 'utf8').toString('base64'))
|
||||
.join('\n'),
|
||||
GH_STUB_PREVIOUS_FILES_B64: previousFiles
|
||||
.map((name) => Buffer.from(name, 'utf8').toString('base64'))
|
||||
.join('\n'),
|
||||
GH_STUB_PERMISSION: permission,
|
||||
// The stub's deny branch pattern-expands this login; '__none__' can
|
||||
// never collide with a real collaborator lookup.
|
||||
GH_STUB_DENY_LOGIN: denyLogin || '__none__',
|
||||
GH_STUB_DENY_PERM: denyPerm,
|
||||
GH_STUB_EDIT_EXIT: String(editExit),
|
||||
GH_STUB_EDIT_ERR: editErr,
|
||||
GH_STUB_ASSIGN: assignResult,
|
||||
GH_STUB_LOAD_FAIL: loadFail,
|
||||
GH_STUB_LOAD_ERR: loadErr,
|
||||
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
||||
GITHUB_STEP_SUMMARY: '',
|
||||
PR_NUMBER: '77',
|
||||
DRY_RUN: String(dryRun),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
assert.equal(result.status, expectExit, result.stderr);
|
||||
return {
|
||||
log: readFileSync(log, 'utf8'),
|
||||
@@ -362,15 +421,28 @@ const assignCall = (login) =>
|
||||
);
|
||||
|
||||
describe('assign-pr-owner: apply boundary', () => {
|
||||
it('assigns the least loaded eligible owner', () => {
|
||||
it('assigns the weighted candidate selected by the random draw', () => {
|
||||
const { log, stdout } = runAssign(false);
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
assert.match(stdout, /assigned @DennisYu07/);
|
||||
assert.match(stdout, /Selection: draw 0\.5; loads /);
|
||||
assert.match(stdout, /DennisYu07:0/);
|
||||
assert.match(stdout, /ZijianZhang989:5/);
|
||||
assert.match(stdout, /; unavailable: none/);
|
||||
// `gh pr edit` requests repository.pullRequest.projectCards, which
|
||||
// GitHub rejects on the gh builds the ECS runners carry.
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
});
|
||||
|
||||
it('can assign a busier owner instead of always selecting the minimum', () => {
|
||||
const { log, stdout } = runAssign(false, {
|
||||
randomValue: 0.9900000000000001,
|
||||
});
|
||||
assert.match(log, assignCall('ZijianZhang989'));
|
||||
assert.match(stdout, /assigned @ZijianZhang989 \(5 open\)/);
|
||||
assert.match(stdout, /Selection: draw 0\.9900000000000001; loads /);
|
||||
});
|
||||
|
||||
it('matches the returned assignee case-insensitively', () => {
|
||||
const { stdout } = runAssign(false, { assignResult: 'lowercase' });
|
||||
assert.match(stdout, /assigned @DennisYu07/);
|
||||
@@ -388,6 +460,10 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
stdout,
|
||||
/skipped — GitHub did not accept @DennisYu07 as an assignee/,
|
||||
);
|
||||
assert.match(stdout, /Selection: draw 0\.5; loads /);
|
||||
assert.match(stdout, /DennisYu07:0/);
|
||||
assert.match(stdout, /ZijianZhang989:5/);
|
||||
assert.match(stdout, /; unavailable: none/);
|
||||
assert.match(stderr, /::warning::GitHub did not add @DennisYu07/);
|
||||
});
|
||||
|
||||
@@ -395,14 +471,17 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
const { log, stdout } = runAssign(true);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /dry-run — would assign @DennisYu07/);
|
||||
assert.match(stdout, /Selection: draw 0\.5; loads /);
|
||||
assert.match(stdout, /DennisYu07:0/);
|
||||
assert.match(stdout, /; unavailable: none/);
|
||||
});
|
||||
|
||||
it('never assigns the PR author to their own work', () => {
|
||||
const { log, stdout } = runAssign(false, {
|
||||
prJson: JSON.stringify({ ...corePr, author: { login: 'DennisYu07' } }),
|
||||
// Without the exclusion the zero-load author would win outright.
|
||||
// This draw would select the zero-load author without the exclusion.
|
||||
});
|
||||
assert.doesNotMatch(log, /--add-assignee DennisYu07\b/);
|
||||
assert.doesNotMatch(log, assignCall('DennisYu07'));
|
||||
assert.match(stdout, /assigned @/);
|
||||
});
|
||||
|
||||
@@ -490,10 +569,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
files: 'packages/core/src/skills/loader.ts',
|
||||
});
|
||||
assert.match(stdout, /falling back to core/);
|
||||
assert.doesNotMatch(
|
||||
log,
|
||||
new RegExp(`--add-assignee ${module.owners[0]}\\b`),
|
||||
);
|
||||
assert.doesNotMatch(log, assignCall(module.owners[0]));
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
});
|
||||
|
||||
@@ -560,20 +636,20 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
assert.doesNotMatch(stderr, /Cannot read open-issue load/);
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
assert.match(stdout, /assigned @DennisYu07 \(0 open\)/);
|
||||
assert.match(stdout, /; unavailable: none/);
|
||||
});
|
||||
|
||||
it('still assigns through rotation when every issue-list lookup fails', () => {
|
||||
it('still draws an owner when every issue-list lookup fails', () => {
|
||||
// @wenshao's second failing run: issues disabled made every load lookup
|
||||
// throw and failed the check. All loads must degrade with a warning and
|
||||
// the rotation must still land an owner — the load metric is a
|
||||
// tie-break heuristic, not a gate on assigning.
|
||||
const owners = policy.areas.find((area) => area.name === 'core').owners;
|
||||
const degraded = new Map(owners.map((owner) => [owner, 0]));
|
||||
const rotated = pickOwner(owners, degraded, 77);
|
||||
// the equal-weight draw must still land an owner.
|
||||
const { log, stdout, stderr } = runAssign(false, { loadFail: 'always' });
|
||||
assert.match(stderr, /Cannot read open-issue load/);
|
||||
assert.match(log, assignCall(rotated));
|
||||
assert.match(stdout, new RegExp(`assigned @${rotated}`));
|
||||
assert.match(log, assignCall('chiga0'));
|
||||
assert.match(stdout, /assigned @chiga0/);
|
||||
const owners = policy.areas.find((area) => area.name === 'core').owners;
|
||||
assert.ok(stdout.includes(`; unavailable: ${owners.join(', ')}`));
|
||||
for (const owner of owners) assert.ok(stdout.includes(`${owner}:0`));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstatSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const AUDIO_TARGETS = [
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-arm64',
|
||||
'linux-x64',
|
||||
'win32-x64',
|
||||
];
|
||||
|
||||
export function audioPrebuildKey(root, ref, workflowRoot) {
|
||||
const git = (...args) => execFileSync('git', args, { cwd: root });
|
||||
const paths = git(
|
||||
'ls-tree',
|
||||
'-r',
|
||||
'--name-only',
|
||||
'-z',
|
||||
ref,
|
||||
'--',
|
||||
'packages/audio-capture',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'.pnpmfile.mjs',
|
||||
'.npmrc',
|
||||
'package.json',
|
||||
)
|
||||
.toString()
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
// Older release refs have no frozen native dependency graph to reuse.
|
||||
if (!paths.includes('pnpm-lock.yaml')) return '';
|
||||
const hash = createHash('sha256');
|
||||
const add = (name, data) =>
|
||||
hash.update(JSON.stringify([name, Buffer.from(data).toString('base64')]));
|
||||
for (const path of paths) {
|
||||
// Tree entries include the content ID and mode without buffering large headers.
|
||||
let data = git('ls-tree', ref, '--', path);
|
||||
if (path === 'packages/audio-capture/package.json') {
|
||||
const manifest = JSON.parse(git('show', `${ref}:${path}`));
|
||||
delete manifest.version;
|
||||
data = JSON.stringify(manifest);
|
||||
} else if (path === 'package.json') {
|
||||
data = JSON.stringify({
|
||||
packageManager: JSON.parse(git('show', `${ref}:${path}`))
|
||||
.packageManager,
|
||||
});
|
||||
}
|
||||
add(path, data);
|
||||
}
|
||||
for (const path of [
|
||||
'.github/workflows/audio-capture-prebuilds.yml',
|
||||
'.github/scripts/audio-prebuilds.mjs',
|
||||
])
|
||||
add(path, readFileSync(join(workflowRoot, path)));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function validateAudioPrebuilds(directory) {
|
||||
if (!lstatSync(directory).isDirectory())
|
||||
throw new Error('Invalid prebuild directory');
|
||||
const targets = readdirSync(directory).sort();
|
||||
if (JSON.stringify(targets) !== JSON.stringify([...AUDIO_TARGETS].sort())) {
|
||||
throw new Error(
|
||||
'Audio prebuilds must contain exactly the five release targets',
|
||||
);
|
||||
}
|
||||
for (const target of targets) {
|
||||
const targetPath = join(directory, target);
|
||||
if (!lstatSync(targetPath).isDirectory())
|
||||
throw new Error(`Invalid target: ${target}`);
|
||||
const files = readdirSync(targetPath);
|
||||
if (!files.length) throw new Error(`Missing native binary: ${target}`);
|
||||
for (const file of files) {
|
||||
const stat = lstatSync(join(targetPath, file));
|
||||
if (!file.endsWith('.node') || !stat.isFile() || stat.size === 0) {
|
||||
throw new Error(`Invalid native binary: ${target}/${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection demands enough retention to outlive the rest of the release;
|
||||
// consumption only demands that the bytes are still there. Re-applying the
|
||||
// margin downstream would reject an artifact that prepare already committed
|
||||
// to, after the fresh-build job was skipped and with no fallback left.
|
||||
export const AUDIO_SELECTION_MARGIN_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
export async function verifyAudioArtifact(
|
||||
github,
|
||||
repo,
|
||||
artifact,
|
||||
key,
|
||||
now = Date.now(),
|
||||
marginMs = 0,
|
||||
) {
|
||||
if (
|
||||
!key ||
|
||||
artifact.name !== `audio-capture-prebuilds-${key}` ||
|
||||
artifact.expired ||
|
||||
!(Date.parse(artifact.expires_at) >= now + marginMs) ||
|
||||
artifact.workflow_run?.head_branch !== 'main' ||
|
||||
artifact.workflow_run.repository_id !==
|
||||
artifact.workflow_run.head_repository_id
|
||||
) {
|
||||
throw new Error(
|
||||
'Audio artifact has an invalid identity, source, or retention',
|
||||
);
|
||||
}
|
||||
const { data: run } = await github.rest.actions.getWorkflowRun({
|
||||
...repo,
|
||||
run_id: artifact.workflow_run.id,
|
||||
});
|
||||
const repository = `${repo.owner}/${repo.repo}`;
|
||||
if (
|
||||
run.status !== 'completed' ||
|
||||
run.conclusion !== 'success' ||
|
||||
run.head_branch !== 'main' ||
|
||||
run.path !== '.github/workflows/release.yml' ||
|
||||
!['workflow_dispatch', 'schedule'].includes(run.event) ||
|
||||
run.repository?.full_name !== repository ||
|
||||
run.head_repository?.full_name !== repository
|
||||
) {
|
||||
throw new Error(
|
||||
'Audio artifact must come from a successful official main release',
|
||||
);
|
||||
}
|
||||
return { artifact_id: artifact.id, run_id: run.id };
|
||||
}
|
||||
|
||||
export async function findAudioArtifact(github, repo, key, warn) {
|
||||
if (!key) return undefined;
|
||||
try {
|
||||
const { data } = await github.rest.actions.listArtifactsForRepo({
|
||||
...repo,
|
||||
name: `audio-capture-prebuilds-${key}`,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const artifact of data.artifacts) {
|
||||
try {
|
||||
return await verifyAudioArtifact(
|
||||
github,
|
||||
repo,
|
||||
artifact,
|
||||
key,
|
||||
Date.now(),
|
||||
AUDIO_SELECTION_MARGIN_MS,
|
||||
);
|
||||
} catch (error) {
|
||||
warn(`Skipping audio artifact ${artifact.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
warn(`Audio reuse lookup failed; rebuilding: ${error.message}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (command === 'key') {
|
||||
console.log(
|
||||
`cache_key=${audioPrebuildKey(process.cwd(), args[0], args[1])}`,
|
||||
);
|
||||
} else if (command === 'validate') {
|
||||
validateAudioPrebuilds(args[0]);
|
||||
} else {
|
||||
throw new Error(`Unknown audio prebuild command: ${command}`);
|
||||
}
|
||||
}
|
||||
@@ -679,7 +679,7 @@ if [[ "${OUTCOME}" == "fixed" ]]; then
|
||||
echo "---"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
|
||||
echo
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->"
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} run=${GITHUB_RUN_ID} -->"
|
||||
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
|
||||
# Regression accounting (af-157). Two distinct facts, both keyed to the
|
||||
# eval marker's window so the consecutive-failure walk reads them with
|
||||
@@ -742,7 +742,7 @@ else
|
||||
echo "---"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
|
||||
echo
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->"
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} run=${GITHUB_RUN_ID} -->"
|
||||
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
|
||||
# No autofix-push marker: a no-op round pushed nothing that could
|
||||
# regress. The regression it OBSERVED still has to be recorded here.
|
||||
@@ -798,9 +798,9 @@ for attempt in 1 2 3; do
|
||||
# no number makes the NEXT round reuse it (the brake's
|
||||
# regression-round walk then charges two rounds as one).
|
||||
if [[ -n "${PUSHED_HEAD:-}" ]]; then
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->"
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} run=${GITHUB_RUN_ID} -->"
|
||||
else
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->"
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} run=${GITHUB_RUN_ID} -->"
|
||||
fi
|
||||
if [[ -n "${PUSHED_HEAD:-}" ]]; then
|
||||
echo "<!-- autofix-push round=${NEXT_ROUND} head=${PUSHED_HEAD} pre=${PUSH_PRE} key=${WINDOW:-none} -->"
|
||||
@@ -838,7 +838,7 @@ if [[ "${OUTCOME}" == "fixed" && "${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}" ]]
|
||||
[.[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-eval "))
|
||||
| select(
|
||||
([ ((.body // "") | scan("<!-- autofix-eval ts=[^ ]+ acted=[^ ]+ round=[0-9]+(?: win=([^ ]+))? -->")) ] | map(.[0] // "none")) as $wins
|
||||
([ ((.body // "") | scan("<!-- autofix-eval ts=[^ ]+ acted=[^ ]+ round=[0-9]+(?: win=([^ ]+))?(?: run=[^ ]+)? -->")) ] | map(.[0] // "none")) as $wins
|
||||
| ($wins | length) > 0 and (($wins | last) == $win))]
|
||||
| sort_by(.created_at) | .[]
|
||||
| (.body | gsub("\r"; "") | split("\n")[0])' "${WORKDIR}/ic.json" 2> /dev/null || true)"
|
||||
|
||||
@@ -88,13 +88,13 @@ describe('ci.yml disk-pressure evidence', () => {
|
||||
);
|
||||
assert.match(tests, /hosttests\[\$\{hosttests:-unknown\}\]/);
|
||||
|
||||
// test_macos and test_windows inline the same sampler (plain echo, no
|
||||
// test_macos_shards and test_windows inline the same sampler (plain echo, no
|
||||
// DISK_SAMPLES append), and no equality pin reaches them: the cross-leg
|
||||
// byte-identity in no-ak-integration-ci.test.js stops at the `( while
|
||||
// true` sentinel by design. Pin the sampler expression per leg, or an
|
||||
// edit that only touches the pinned test-job copy ships green while
|
||||
// these nightly-only lanes silently drop `load`/`hosttests`.
|
||||
for (const jobName of ['test_macos', 'test_windows']) {
|
||||
for (const jobName of ['test_macos_shards', 'test_windows']) {
|
||||
const leg = ciJobs[jobName].steps.find(
|
||||
(candidate) => candidate.name === 'Run tests and generate reports',
|
||||
);
|
||||
@@ -257,7 +257,7 @@ describe('ci.yml disk-pressure evidence', () => {
|
||||
// reaches them ('Dump disk state on failure' is this lane's alone). Pin
|
||||
// them textually like the sampler loop above — not executed, because
|
||||
// test_windows greps /proc/meminfo, which Git Bash lacks.
|
||||
for (const jobName of ['test', 'test_macos', 'test_windows']) {
|
||||
for (const jobName of ['test', 'test_macos_shards', 'test_windows']) {
|
||||
const leg = ciJobs[jobName].steps.find(
|
||||
(candidate) => candidate.name === 'Run tests and generate reports',
|
||||
);
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
// qwen-autofix.yml scan lane, the trusted-PR lanes of #13245, and the
|
||||
// control-plane jobs that run no PR code.
|
||||
//
|
||||
// classify_pr carries the routing logic TWICE — the `runs-on` expression
|
||||
// (which selects the classify job's own runner) and the `pick_runner` shell
|
||||
// step (which publishes `ubuntu_runner` for downstream Linux jobs). If
|
||||
// they drift, classify and the Test job land on different pools. These tests
|
||||
// evaluate BOTH against the same event matrix — including the negative
|
||||
// associations that must stay hosted — and assert they agree.
|
||||
// classify_pr's own runner is unconditional ECS (kill-switch excepted):
|
||||
// the gate executes only trusted base-SHA code with a read-only token, and
|
||||
// keeping it on hosted for forks queued the whole fork CI chain behind a
|
||||
// scarce pool (2026-10-31 decision, same accepted risk class as lint).
|
||||
// The association routing for DOWNSTREAM lanes lives in the `pick_runner`
|
||||
// shell step, which publishes `ubuntu_runner` (association-gated) and
|
||||
// `lint_runner` (every pull request reaches the fleet). The matrix below
|
||||
// pins the gate's unconditional routing AND that ubuntu_runner keeps the
|
||||
// association gate for the test/integration lanes.
|
||||
//
|
||||
// test_windows carries a deliberately different policy. A pull_request run
|
||||
// executes the workflow YAML from the PR's own merge commit, so any trust
|
||||
@@ -17,7 +20,7 @@
|
||||
// persistent pool.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
@@ -46,15 +49,10 @@ const pickRunner = ciDoc.jobs.classify_pr.steps.find(
|
||||
(s) => s.id === 'pick_runner',
|
||||
);
|
||||
|
||||
// GitHub expression semantics for the classify runs-on, restricted to the
|
||||
// routing-relevant inputs: contains(list, '') is false, a missing
|
||||
// GitHub expression semantics for the runs-on expressions, restricted to
|
||||
// the routing-relevant inputs: contains(list, '') is false, a missing
|
||||
// pull_request (merge_group / dispatch) yields '' for both head.repo and
|
||||
// author_association.
|
||||
function simulateRunsOn({ ecsDisabled, sameRepo, assoc, mergeGroup }) {
|
||||
const trusted = TRUSTED.includes(assoc);
|
||||
const ecs = !ecsDisabled && (sameRepo || trusted || mergeGroup);
|
||||
return ecs ? ECS : HOSTED;
|
||||
}
|
||||
|
||||
// Evaluates a real `runs-on` expression text with the routing inputs
|
||||
// substituted, leaving only the &&/||/parenthesis skeleton — which matches
|
||||
@@ -117,8 +115,8 @@ function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
|
||||
}
|
||||
|
||||
// Executes the real pick_runner shell with the same inputs and returns the
|
||||
// selected runner exactly as CI would publish it.
|
||||
function runPickRunner({
|
||||
// selected runners exactly as CI would publish them.
|
||||
function runPickRunnerOutputs({
|
||||
ecsDisabled,
|
||||
sameRepo,
|
||||
assoc,
|
||||
@@ -140,13 +138,34 @@ function runPickRunner({
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const outputs = existsSync(outputFile)
|
||||
? readFileSync(outputFile, 'utf8')
|
||||
: '';
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
assert.equal(result.status, 0, `pick_runner failed: ${result.stderr}`);
|
||||
const line = result.stdout
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('Selected Linux runner: '));
|
||||
assert.ok(line, `no selection in pick_runner output: ${result.stdout}`);
|
||||
return line.slice('Selected Linux runner: '.length);
|
||||
const select = (prefix) => {
|
||||
const line = result.stdout.split('\n').find((l) => l.startsWith(prefix));
|
||||
assert.ok(line, `no selection in pick_runner output: ${result.stdout}`);
|
||||
return line.slice(prefix.length);
|
||||
};
|
||||
const ubuntuRunner = select('Selected Linux runner: ');
|
||||
const lintRunner = select('Selected lint runner: ');
|
||||
// The stdout echo and the published output must agree — a rename of the
|
||||
// GITHUB_OUTPUT key would otherwise pass the echo parse and break every
|
||||
// downstream runs-on at runtime.
|
||||
assert.ok(
|
||||
outputs.split('\n').includes(`ubuntu_runner=${ubuntuRunner}`),
|
||||
`ubuntu_runner not published: ${outputs}`,
|
||||
);
|
||||
assert.ok(
|
||||
outputs.split('\n').includes(`lint_runner=${lintRunner}`),
|
||||
`lint_runner not published: ${outputs}`,
|
||||
);
|
||||
return { ubuntuRunner, lintRunner };
|
||||
}
|
||||
|
||||
function runPickRunner(inputs) {
|
||||
return runPickRunnerOutputs(inputs).ubuntuRunner;
|
||||
}
|
||||
|
||||
const ASSOCIATIONS = [
|
||||
@@ -159,25 +178,31 @@ const ASSOCIATIONS = [
|
||||
];
|
||||
|
||||
describe('ci.yml classify_pr runner routing', () => {
|
||||
it('the expression and the shell step agree on every association', () => {
|
||||
it('the gate routes every association to ECS; the kill-switch wins', () => {
|
||||
// classify executes trusted base-SHA code only, so its own runner needs
|
||||
// no association gate — a hosted gate queued the entire fork CI chain.
|
||||
// The downstream ubuntu_runner keeps the gate (next test).
|
||||
for (const sameRepo of [true, false]) {
|
||||
for (const assoc of ASSOCIATIONS) {
|
||||
const expected = simulateRunsOn({
|
||||
ecsDisabled: false,
|
||||
sameRepo,
|
||||
assoc,
|
||||
mergeGroup: false,
|
||||
});
|
||||
const actual = runPickRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
});
|
||||
assert.equal(
|
||||
actual,
|
||||
expected,
|
||||
`drift for sameRepo=${sameRepo} assoc='${assoc}'`,
|
||||
assert.deepEqual(
|
||||
evalRunsOn(classifyRunsOn, {
|
||||
ecsDisabled: false,
|
||||
eventName: 'pull_request',
|
||||
sameRepo,
|
||||
assoc,
|
||||
}),
|
||||
JSON.parse(ECS),
|
||||
`gate sameRepo=${sameRepo} assoc='${assoc}' must reach ECS`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
evalRunsOn(classifyRunsOn, {
|
||||
ecsDisabled: true,
|
||||
eventName: 'pull_request',
|
||||
sameRepo,
|
||||
assoc,
|
||||
}),
|
||||
JSON.parse(HOSTED),
|
||||
`kill-switch must revert the gate (assoc='${assoc}')`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -259,18 +284,248 @@ describe('ci.yml classify_pr runner routing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('the runs-on expression keeps the trusted clause and kill-switch', () => {
|
||||
// Structural pins for the expression half of the drift guard — the
|
||||
// simulation above re-implements it, so pin the real text too.
|
||||
assert.match(
|
||||
classifyRunsOn,
|
||||
/contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/,
|
||||
);
|
||||
it('the gate expression is unconditional except the kill-switch', () => {
|
||||
// Structural pins: no association/head-repo clause may creep back into
|
||||
// the gate (it would re-queue fork CI on hosted), and the kill-switch
|
||||
// must stay the single escape hatch.
|
||||
assert.doesNotMatch(classifyRunsOn, /author_association/);
|
||||
assert.doesNotMatch(classifyRunsOn, /head\.repo\.full_name/);
|
||||
assert.match(
|
||||
classifyRunsOn,
|
||||
/vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/,
|
||||
);
|
||||
assert.match(classifyRunsOn, /github\.event_name == 'merge_group'/);
|
||||
assert.match(classifyRunsOn, /ecs-qwen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ci.yml lint_runner routing', () => {
|
||||
const ARM = '["self-hosted", "linux", "arm64"]';
|
||||
const lintRunner = (inputs) => runPickRunnerOutputs(inputs).lintRunner;
|
||||
|
||||
it('routes every pull request to the fleet, untrusted forks included', () => {
|
||||
// The maintainer decision this pins: Lint & Static executes PR code on
|
||||
// the shared pool for ALL authors. If this matrix ever flips back to
|
||||
// hosted for the negative associations without a deliberate decision,
|
||||
// the lane silently regrows its hosted quota spend.
|
||||
for (const sameRepo of [true, false]) {
|
||||
for (const assoc of ASSOCIATIONS) {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
}),
|
||||
ECS,
|
||||
`lint sameRepo=${sameRepo} assoc='${assoc}' must route to ECS`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the kill-switch and hosted-fleet overrides', () => {
|
||||
for (const assoc of ASSOCIATIONS) {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: true,
|
||||
sameRepo: true,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
}),
|
||||
HOSTED,
|
||||
'kill-switch must revert even lint to hosted',
|
||||
);
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
fleet: 'hosted',
|
||||
}),
|
||||
HOSTED,
|
||||
'LINUX_RUNNER_FLEET=hosted must keep lint hosted',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('follows the arm64 fleet selector', () => {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: 'NONE',
|
||||
eventName: 'pull_request',
|
||||
fleet: 'arm64',
|
||||
}),
|
||||
ARM,
|
||||
);
|
||||
});
|
||||
|
||||
it('inherits ubuntu_runner on non-PR events and dispatch inputs', () => {
|
||||
for (const eventName of ['merge_group', 'push']) {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName,
|
||||
}),
|
||||
ECS,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'workflow_dispatch',
|
||||
dispatch: 'self-hosted',
|
||||
}),
|
||||
ECS,
|
||||
);
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'workflow_dispatch',
|
||||
dispatch: 'hosted',
|
||||
}),
|
||||
HOSTED,
|
||||
);
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: true,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'workflow_dispatch',
|
||||
dispatch: 'self-hosted',
|
||||
}),
|
||||
HOSTED,
|
||||
);
|
||||
});
|
||||
|
||||
it('classify_pr publishes lint_runner from pick_runner', () => {
|
||||
assert.equal(
|
||||
ciDoc.jobs.classify_pr.outputs.lint_runner,
|
||||
'${{ steps.pick_runner.outputs.lint_runner }}',
|
||||
);
|
||||
});
|
||||
|
||||
it('lint_and_static consumes lint_runner for runs-on and timeout', () => {
|
||||
assert.equal(
|
||||
String(ciDoc.jobs.lint_and_static['runs-on']),
|
||||
'${{ fromJSON(needs.classify_pr.outputs.lint_runner || \'["ubuntu-latest"]\') }}',
|
||||
);
|
||||
assert.match(
|
||||
String(ciDoc.jobs.lint_and_static['timeout-minutes']),
|
||||
/needs\.classify_pr\.outputs\.lint_runner/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps both outputs on one fleet ladder', () => {
|
||||
// The lint arm assigns from the same resolved fleet labels as
|
||||
// ubuntu_runner; a fleet value neither models must not split them when
|
||||
// the trust clause is satisfied (a second literal ladder drifts silently
|
||||
// because the timeout reads lint_runner and still reports a pool ceiling).
|
||||
for (const fleet of ['', 'x64', 'arm64', 'future-fleet']) {
|
||||
const out = runPickRunnerOutputs({
|
||||
ecsDisabled: false,
|
||||
sameRepo: true,
|
||||
assoc: 'NONE',
|
||||
eventName: 'pull_request',
|
||||
fleet,
|
||||
});
|
||||
assert.equal(out.lintRunner, out.ubuntuRunner, `fleet='${fleet}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ci.yml lint HOME isolation', () => {
|
||||
// This guard lives in the routing battery (HELPER_TESTS) because it gates
|
||||
// every full-profile PR; the scripts/tests vitest suites only run after the
|
||||
// merge queue. lint_runner admits untrusted forks to a pool where
|
||||
// RUNNER_TEMP survives across jobs, so the isolated HOME must be run-unique
|
||||
// and cleared before use — a fixed path hands a fork's ~/.npmrc to the next
|
||||
// job on the registration. The ubuntu_runner lanes' association clause is
|
||||
// throughput policy, not an enforceable boundary (a pull_request run
|
||||
// executes the PR's own YAML); their pre-existing HOME properties are
|
||||
// separately owned.
|
||||
const ungated = Object.entries(ciDoc.jobs).filter(([, job]) =>
|
||||
/outputs\.lint_runner/.test(String(job['runs-on'] ?? '')),
|
||||
);
|
||||
|
||||
it('scopes the ungated routing to the single recorded lane', () => {
|
||||
// The decision record scopes the boundary change to Lint & Static alone;
|
||||
// a superset must force a deliberate record update, not pass silently.
|
||||
assert.deepEqual(
|
||||
ungated.map(([id]) => id),
|
||||
['lint_and_static'],
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes a run-unique HOME from the first step, not job env', () => {
|
||||
for (const [id, job] of ungated) {
|
||||
// jobs.<job_id>.env cannot read the runner context — GitHub rejects the
|
||||
// whole workflow file (actionlint 1.7.12 witnesses it).
|
||||
assert.equal(job.env?.HOME, undefined, `${id} job-level HOME`);
|
||||
assert.equal(
|
||||
job.env?.USERPROFILE,
|
||||
undefined,
|
||||
`${id} job-level USERPROFILE`,
|
||||
);
|
||||
const steps = job.steps ?? [];
|
||||
assert.equal(steps[0]?.name, 'Create isolated HOME', id);
|
||||
const run = String(steps[0]?.run ?? '');
|
||||
assert.ok(
|
||||
run.includes('GITHUB_RUN_ID') && run.includes('GITHUB_RUN_ATTEMPT'),
|
||||
`${id} HOME path must be run-unique`,
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('rm -rf') && run.indexOf('rm -rf') < run.indexOf('mkdir'),
|
||||
`${id} must clear the path before creating it`,
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('HOME=') &&
|
||||
run.includes('USERPROFILE=') &&
|
||||
run.includes('GITHUB_ENV'),
|
||||
`${id} must publish HOME/USERPROFILE via GITHUB_ENV`,
|
||||
);
|
||||
// Linter archives are SHA-256-verified on every cache hit (lint.js), so
|
||||
// the shared tool cache is safe for them. Corepack does NOT re-verify
|
||||
// extracted entries on hit (reproduced on 0.34.6 and 0.36.0), so
|
||||
// COREPACK_HOME must stay inside the run-unique HOME — a shared one is
|
||||
// a cross-job poisoning channel. Deleting either line reds this.
|
||||
assert.ok(
|
||||
run.includes('XDG_CACHE_HOME=') && run.includes('RUNNER_TOOL_CACHE'),
|
||||
`${id} must share the verified linter-archive cache`,
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('COREPACK_HOME=') &&
|
||||
run.includes('"${home}/.cache/node/corepack"'),
|
||||
`${id} COREPACK_HOME must stay inside the run-unique HOME`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('reclaims the isolated HOME before the failure collector', () => {
|
||||
for (const [id, job] of ungated) {
|
||||
const steps = job.steps ?? [];
|
||||
const names = steps.map((s) => s.name);
|
||||
const clean = steps.find((s) => s.name === 'Clean isolated HOME');
|
||||
assert.ok(clean, `${id} cleanup step missing`);
|
||||
assert.equal(clean.if, '${{ always() }}', id);
|
||||
assert.ok(
|
||||
names.indexOf('Clean isolated HOME') <
|
||||
names.indexOf('Upload disk-pressure samples'),
|
||||
`${id} collector must stay last`,
|
||||
);
|
||||
// The constructed path, never $HOME: a failed init must not aim the
|
||||
// removal at the account's real home.
|
||||
assert.ok(String(clean.run).includes('qwen-lint-home-'), id);
|
||||
assert.ok(!String(clean.run).includes('"$HOME"'), id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -932,7 +1187,7 @@ describe('trusted-PR lanes moved off the hosted queue (#13245)', () => {
|
||||
assert.match(run('Build Java components'), /-Dmaven\.repo\.local=/);
|
||||
assert.match(
|
||||
run('Build Java components'),
|
||||
/rm -rf -- "\$\{repo\}\/com\/alibaba\/qwencode-sdk" "\$\{repo\}\/com\/alibaba\/qwen-managed-agent-server"/,
|
||||
/rm -rf -- "\$\{repo\}\/com\/alibaba\/qwencode-sdk" "\$\{repo\}\/com\/alibaba\/"qwen-managed-agent-\*/,
|
||||
);
|
||||
assert.match(
|
||||
run('Assemble reference distribution'),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
# Reject symlinked or non-canonical paths before recursive changes.
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -L "$RWS" ]; then
|
||||
echo "::error::refusing to wipe: runner workspace is a symlink: ${RWS}"
|
||||
exit 1
|
||||
fi
|
||||
RWS_LEX="$(realpath -m -s -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
if [ "$RWS" != "$RWS_LEX" ]; then
|
||||
echo "::error::refusing to wipe: runner workspace resolves through a symlinked component: ${RWS_LEX} resolves to ${RWS}"
|
||||
exit 1
|
||||
fi
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
WS="${GITHUB_WORKSPACE:?}"
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
# Heal only a corrupt leaf whose canonical parent remains contained.
|
||||
if [ -L "$WS" ] || [ ! -d "$WS" ]; then
|
||||
HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; }
|
||||
case "$HEAL_PARENT" in
|
||||
"$RWS"|"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;;
|
||||
esac
|
||||
if [ -L "$WS" ]; then
|
||||
# Keep attacker-controlled link text out of workflow commands.
|
||||
heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '<unreadable>')"
|
||||
heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)"
|
||||
echo "::warning::healing workspace ${WS}: it was a symlink"
|
||||
printf 'heal: %s pointed at %s\n' "$WS" "$heal_target"
|
||||
else
|
||||
echo "::warning::healing workspace ${WS}: it was not a directory"
|
||||
fi
|
||||
# Remove the raw link path; never resolve its target.
|
||||
rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; }
|
||||
mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; }
|
||||
fi
|
||||
WS_LEX="$(realpath -m -s -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; }
|
||||
WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; }
|
||||
if [ "$WS" != "$WS_LEX" ]; then
|
||||
echo "::error::refusing to wipe: workspace resolves through a symlinked component: ${WS_LEX} resolves to ${WS}"
|
||||
exit 1
|
||||
fi
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
case "$WS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;;
|
||||
esac
|
||||
# Deny known roots, then allow only runner-workspace descendants.
|
||||
case "$WS" in
|
||||
/|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;;
|
||||
esac
|
||||
case "$WS" in
|
||||
"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;;
|
||||
esac
|
||||
# Path geometry is validated before ownership changes.
|
||||
RUNNER_UID="$(id -u)"
|
||||
RUNNER_GID="$(id -g)"
|
||||
if [ "$RUNNER_UID" != "0" ]; then
|
||||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||||
fi
|
||||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||||
find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
# Isolate Git, npm, Docker, and gh state inherited across pool jobs.
|
||||
release_state="$(mktemp -d "${RUNNER_TEMP:?}/release-state.XXXXXX")" || exit 1
|
||||
: > "${release_state}/gitconfig" || exit 1
|
||||
: > "${release_state}/npmrc" || exit 1
|
||||
mkdir "${release_state}/docker" || exit 1
|
||||
mkdir "${release_state}/gh" || exit 1
|
||||
{
|
||||
echo 'GIT_CONFIG_COUNT=0'
|
||||
echo 'GIT_CONFIG_NOSYSTEM=1'
|
||||
echo 'GIT_CONFIG_PARAMETERS='
|
||||
echo "GIT_CONFIG_GLOBAL=${release_state}/gitconfig"
|
||||
echo "NPM_CONFIG_USERCONFIG=${release_state}/npmrc"
|
||||
echo "DOCKER_CONFIG=${release_state}/docker"
|
||||
echo "GH_CONFIG_DIR=${release_state}/gh"
|
||||
} >> "${GITHUB_ENV:?}"
|
||||
mkdir "${release_state}/bin"
|
||||
echo "NPM_CONFIG_PREFIX=${release_state}" >> "${GITHUB_ENV}"
|
||||
echo "${release_state}/bin" >> "${GITHUB_PATH:?}"
|
||||
@@ -9,6 +9,15 @@ set -eo pipefail
|
||||
|
||||
step="${1:?usage: run-release-step.sh <step>}"
|
||||
|
||||
commit_release_version() {
|
||||
git add package.json pnpm-lock.yaml packages/*/package.json packages/channels/*/package.json integrations/*/package.json integrations/*/qwen-extension.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "No version changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): ${RELEASE_TAG}"
|
||||
fi
|
||||
}
|
||||
|
||||
publish_package() {
|
||||
local directory="$1"
|
||||
(
|
||||
@@ -121,14 +130,48 @@ case "${step}" in
|
||||
npm run release:version "${RELEASE_VERSION}"
|
||||
;;
|
||||
|
||||
commit-release-branch)
|
||||
commit_release_version
|
||||
;;
|
||||
|
||||
pack-release)
|
||||
handoff="${RUNNER_TEMP:?}/release-publish-build"
|
||||
rm -rf -- "$handoff"
|
||||
mkdir -p "$handoff"
|
||||
"$0" pack-build
|
||||
mv "${RUNNER_TEMP}/release-build.tgz" "$handoff/"
|
||||
tar -czf "$handoff/audio-prebuilds.tgz" packages/audio-capture/prebuilds
|
||||
release_commit="$(git rev-parse HEAD)"
|
||||
if [[ "$release_commit" != "${RELEASE_SHA:?}" ]]; then
|
||||
git bundle create "$handoff/version.bundle" "${RELEASE_SHA}..refs/heads/${BRANCH_NAME:?}"
|
||||
fi
|
||||
echo "release_commit=$release_commit" >> "${GITHUB_OUTPUT:?}"
|
||||
;;
|
||||
|
||||
restore-release)
|
||||
handoff="${RUNNER_TEMP:?}/release-publish-build"
|
||||
[[ "${RELEASE_COMMIT:?}" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
[[ "${RELEASE_SHA:?}" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
branch="release/${RELEASE_TAG:?}"
|
||||
git check-ref-format "refs/heads/$branch"
|
||||
if [[ "$RELEASE_COMMIT" == "$RELEASE_SHA" ]]; then
|
||||
git switch -c "$branch" "$RELEASE_SHA"
|
||||
else
|
||||
git bundle verify "$handoff/version.bundle"
|
||||
git fetch "$handoff/version.bundle" "refs/heads/$branch:refs/heads/$branch"
|
||||
[[ "$(git rev-parse "$branch")" == "$RELEASE_COMMIT" ]] || exit 1
|
||||
[[ "$(git rev-list --parents -n 1 "$RELEASE_COMMIT")" == "$RELEASE_COMMIT $RELEASE_SHA" ]] || exit 1
|
||||
git switch "$branch"
|
||||
fi
|
||||
tar -xzf "$handoff/release-build.tgz"
|
||||
tar -xzf "$handoff/audio-prebuilds.tgz"
|
||||
test -s dist/cli.js
|
||||
test -s dist/review-sources.sha256
|
||||
;;
|
||||
|
||||
push-release-branch)
|
||||
release_branch_name="${BRANCH_NAME:?}"
|
||||
git add package.json pnpm-lock.yaml packages/*/package.json packages/channels/*/package.json integrations/*/package.json integrations/*/qwen-extension.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "No version changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): ${RELEASE_TAG}"
|
||||
fi
|
||||
commit_release_version
|
||||
if [[ "${IS_DRY_RUN}" == "false" ]]; then
|
||||
# Keep the write credential unavailable until the push step needs it.
|
||||
export GH_TOKEN="${CI_BOT_PAT}"
|
||||
@@ -278,6 +321,8 @@ case "${step}" in
|
||||
"quality:${QUALITY_RESULT}" \
|
||||
"integration_none:${INTEGRATION_NONE_RESULT}" \
|
||||
"integration_docker:${INTEGRATION_DOCKER_RESULT}" \
|
||||
"audio_capture_prebuilds:${AUDIO_PREBUILDS_RESULT}" \
|
||||
"build_release:${BUILD_RELEASE_RESULT}" \
|
||||
"publish:${PUBLISH_RESULT}"; do
|
||||
name="${job%%:*}"
|
||||
result="${job#*:}"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, renameSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export function validateSdkTarball(tarball, version, cliVersion) {
|
||||
const read = (file) =>
|
||||
execFileSync('tar', ['-xOf', tarball, `package/${file}`], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const manifest = JSON.parse(read('package.json'));
|
||||
if (manifest.name !== '@qwen-code/sdk' || manifest.version !== version) {
|
||||
throw new Error('SDK tarball name/version does not match the release');
|
||||
}
|
||||
const files = new Set(
|
||||
execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' })
|
||||
.trim()
|
||||
.split('\n'),
|
||||
);
|
||||
const targets = (value) =>
|
||||
typeof value === 'string'
|
||||
? [value]
|
||||
: Object.values(value ?? {}).flatMap(targets);
|
||||
for (const file of [
|
||||
'dist/cli/cli.js',
|
||||
'dist/BUNDLED_CLI_VERSION',
|
||||
...targets(manifest.exports),
|
||||
...targets(manifest.bin),
|
||||
]) {
|
||||
if (!files.has(`package/${file.replace(/^\.\//, '')}`))
|
||||
throw new Error(`SDK tarball is missing ${file}`);
|
||||
}
|
||||
if (read('dist/BUNDLED_CLI_VERSION').trim() !== cliVersion)
|
||||
throw new Error('Bundled CLI version does not match');
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function packSdk(sdkDirectory, destination, version, cliVersion) {
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const output = execFileSync(
|
||||
'npm',
|
||||
[
|
||||
'pack',
|
||||
'--ignore-scripts',
|
||||
'--json',
|
||||
'--pack-destination',
|
||||
resolve(destination),
|
||||
],
|
||||
{ cwd: sdkDirectory, encoding: 'utf8' },
|
||||
);
|
||||
const [{ filename }] = JSON.parse(output);
|
||||
const tarball = join(destination, filename);
|
||||
validateSdkTarball(tarball, version, cliVersion);
|
||||
renameSync(tarball, join(destination, 'sdk.tgz'));
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
const [command, directory, version, cliVersion] = process.argv.slice(2);
|
||||
if (command === 'pack')
|
||||
packSdk('packages/sdk-typescript', directory, version, cliVersion);
|
||||
else if (command === 'validate')
|
||||
validateSdkTarball(join(directory, 'sdk.tgz'), version, cliVersion);
|
||||
else throw new Error('Expected pack or validate');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
managed_version="$(mvn --batch-mode --no-transfer-progress help:evaluate -Dexpression=project.version -q -DforceStdout -Dstyle.color=never)"
|
||||
mvn --batch-mode --no-transfer-progress -pl server -am -DskipTests -Dgpg.skip=true install
|
||||
mvn --batch-mode --no-transfer-progress -f verification/src/test/embedding-host/pom.xml -Dmanaged-agent.version="${managed_version}" -Dtest=EmbeddingHostTest -DfailIfNoTests=true test
|
||||
mvn --batch-mode --no-transfer-progress -f verification/src/test/embedding-host/pom.xml -Dmanaged-agent.version="${managed_version}" -Dtest=EnabledHostTest -DfailIfNoTests=true -Pembedded test
|
||||
@@ -1,7 +1,7 @@
|
||||
# PR-stage limits: jobs, declared timeout-minutes, jobs without fixed timeout, file.
|
||||
# Update the corresponding entry when intentionally increasing a budget.
|
||||
1 5 0 assign-pr-owner.yml
|
||||
8 300 4 ci.yml
|
||||
9 305 4 ci.yml
|
||||
4 90 1 live-host-release.yml
|
||||
1 30 0 live-host.yml
|
||||
3 15 1 managed-agent-gate.yml
|
||||
|
||||
@@ -14,20 +14,20 @@
|
||||
# real, bump the number and say why in the PR.
|
||||
2226 assign-issue-owner.yml
|
||||
3555 assign-pr-owner.yml
|
||||
3480 audio-capture-prebuilds.yml
|
||||
7107 audio-capture-prebuilds.yml
|
||||
9023 auto-minimize-spam.yml
|
||||
9256 build-and-publish-image.yml
|
||||
9551 build-and-publish-image.yml
|
||||
55276 cd-cua-driver.yml
|
||||
2222 cd-mobile-mcp.yml
|
||||
156377 ci.yml
|
||||
161281 ci.yml
|
||||
4193 codeql.yml
|
||||
9389 comment-attachment-guard.yml
|
||||
2090 desktop-packaging-check.yml
|
||||
44409 desktop-release.yml
|
||||
45089 desktop-release.yml
|
||||
2038 docs-page-action.yml
|
||||
10005 dsw-swe-verified-release.yml
|
||||
40188 e2e.yml
|
||||
11634 finalize-release.yml
|
||||
13135 finalize-release.yml
|
||||
16647 live-host-release.yml
|
||||
5950 live-host.yml
|
||||
14543 managed-agent-gate.yml
|
||||
@@ -39,7 +39,7 @@
|
||||
6495 pr-self-report-label.yml
|
||||
9646 qwen-autofix-fork-bridge.yml
|
||||
5942 qwen-autofix-fork-signal.yml
|
||||
466739 qwen-autofix.yml
|
||||
469584 qwen-autofix.yml
|
||||
7061 qwen-ci-flaky-rerun.yml
|
||||
272192 qwen-code-pr-review.yml
|
||||
107075 qwen-fleet-shepherd.yml
|
||||
@@ -47,20 +47,20 @@
|
||||
5760 qwen-pr-safety-precheck.yml
|
||||
3243 qwen-review-runner-schedule.yml
|
||||
27648 qwen-triage-finalize.yml
|
||||
362377 qwen-triage.yml
|
||||
15171 release-chrome-extension.yml
|
||||
375110 qwen-triage.yml
|
||||
15726 release-chrome-extension.yml
|
||||
26801 release-managed-agent-reference.yml
|
||||
13464 release-managed-runtime.yml
|
||||
11950 release-sdk-java.yml
|
||||
22345 release-sdk-python.yml
|
||||
20008 release-sdk.yml
|
||||
16655 release-vscode-companion.yml
|
||||
35955 release.yml
|
||||
31270 release-sdk.yml
|
||||
17941 release-vscode-companion.yml
|
||||
45270 release.yml
|
||||
44285 repo-hygiene.yml
|
||||
1079 scorecard-monthly.yml
|
||||
9751 sdk-java-contract-open-prs.yml
|
||||
9555 sdk-java-flyway-open-prs.yml
|
||||
144802 sdk-java.yml
|
||||
150332 sdk-java.yml
|
||||
3886 sdk-python.yml
|
||||
5595 security-checks.yml
|
||||
6777 serve-ab-publish.yml
|
||||
@@ -69,7 +69,7 @@
|
||||
11328 sync-desktop-to-oss.yml
|
||||
10018 sync-live-host-to-oss.yml
|
||||
11206 sync-release-to-oss.yml
|
||||
22589 tui-parity.yml
|
||||
32064 tui-parity.yml
|
||||
7187 update-ecs-runner-qwen.yml
|
||||
6206 verify-landlock-helper.yml
|
||||
5575 verify-maven-repo.yml
|
||||
|
||||
@@ -17,6 +17,14 @@ name: 'Audio Capture Prebuilds'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
type: 'string'
|
||||
required: true
|
||||
cache_key:
|
||||
type: 'string'
|
||||
required: false
|
||||
default: ''
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -29,7 +37,7 @@ defaults:
|
||||
jobs:
|
||||
build:
|
||||
name: 'prebuild ${{ matrix.os }} (${{ matrix.arch }})'
|
||||
runs-on: '${{ matrix.runner }}'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'' && startsWith(matrix.os, ''ubuntu'')) && fromJSON(format(''["self-hosted", "linux", "{0}", "ecs-qwen-release-host"]'', matrix.arch)) || matrix.runner }}'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -50,26 +58,62 @@ jobs:
|
||||
runner: 'windows-2022'
|
||||
arch: 'x64'
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
# Install only this package's deps (skip the node-gyp-build install hook;
|
||||
# prebuildify does its own compile below).
|
||||
ref: '${{ inputs.ref || github.sha }}'
|
||||
persist-credentials: false
|
||||
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
if: "${{ !startsWith(matrix.os, 'ubuntu') }}"
|
||||
with:
|
||||
node-version: '22.22.3'
|
||||
# Use the lock that participates in the reuse fingerprint. Older refs
|
||||
# without pnpm retain the original build path and cannot seed reuse.
|
||||
- name: 'Install build deps'
|
||||
run: 'npm install --no-workspaces --ignore-scripts --no-audit --no-fund'
|
||||
if: "${{ !startsWith(matrix.os, 'ubuntu') }}"
|
||||
working-directory: '.'
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
if [ -f pnpm-lock.yaml ]; then
|
||||
corepack pnpm --filter @qwen-code/audio-capture install --frozen-lockfile --ignore-scripts --reporter=append-only
|
||||
else
|
||||
cd packages/audio-capture
|
||||
npm install --no-workspaces --ignore-scripts --no-audit --no-fund
|
||||
fi
|
||||
- name: 'Build prebuild'
|
||||
if: "${{ !startsWith(matrix.os, 'ubuntu') }}"
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm run prebuildify
|
||||
npm run prebuildify -- --target 22.22.3
|
||||
# Cross-compile the Intel (x64) slice on this arm64 runner instead of
|
||||
# a separate macos-13 runner (frameworks are universal). See #5642.
|
||||
if [ "$RUNNER_OS" = 'macOS' ]; then
|
||||
npm run prebuildify -- --arch x64
|
||||
npm run prebuildify -- --target 22.22.3 --arch x64
|
||||
fi
|
||||
# Use the same Linux toolchain on hosted and ECS runners for cache identity.
|
||||
- name: 'Build Linux prebuild'
|
||||
if: "${{ startsWith(matrix.os, 'ubuntu') }}"
|
||||
working-directory: '.'
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
docker run --rm --user "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp -e COREPACK_HOME=/tmp/corepack \
|
||||
-v "$PWD:/build" -w /build node:22.22.3-bookworm \
|
||||
bash -euc '
|
||||
if [ -f pnpm-lock.yaml ]; then
|
||||
corepack pnpm --filter @qwen-code/audio-capture install --frozen-lockfile --ignore-scripts --reporter=append-only
|
||||
else
|
||||
cd packages/audio-capture
|
||||
npm install --no-workspaces --ignore-scripts --no-audit --no-fund
|
||||
cd ../..
|
||||
fi
|
||||
cd packages/audio-capture
|
||||
npm run prebuildify -- --target 22.22.3
|
||||
'
|
||||
- name: 'Upload prebuild'
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
overwrite: true
|
||||
name: 'prebuilds-${{ matrix.os }}-${{ matrix.artifact_suffix || matrix.arch }}'
|
||||
path: 'packages/audio-capture/prebuilds/'
|
||||
if-no-files-found: 'error'
|
||||
@@ -77,19 +121,9 @@ jobs:
|
||||
collect:
|
||||
name: 'collect prebuilds'
|
||||
needs: 'build'
|
||||
# Rides the same dedicated release-only machine as release.yml's publish
|
||||
# job, opted into via ECS_RELEASE_HOST_RUNNER_ENABLED. On that persistent
|
||||
# workspace a leftover prebuilds/ directory would leak into the merged
|
||||
# artifact (download-artifact merge-multiple merges into existing paths),
|
||||
# so the step below clears it first.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
steps:
|
||||
- name: 'Clear stale prebuilds (self-hosted)'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
# run steps resolve against defaults.run.working-directory
|
||||
# (packages/audio-capture), unlike the action inputs below which stay
|
||||
# workspace-relative.
|
||||
run: 'rm -rf prebuilds'
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Merge per-platform prebuilds'
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
@@ -97,11 +131,41 @@ jobs:
|
||||
pattern: 'prebuilds-*'
|
||||
merge-multiple: true
|
||||
path: 'packages/audio-capture/prebuilds'
|
||||
- name: 'List collected prebuilds'
|
||||
run: 'find prebuilds -type f'
|
||||
- name: 'Checkout audio workflow helper'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.workflow_sha }}'
|
||||
persist-credentials: false
|
||||
sparse-checkout: |-
|
||||
.github/scripts/audio-prebuilds.mjs
|
||||
.github/actions/self-hosted-node
|
||||
sparse-checkout-cone-mode: false
|
||||
path: '.audio-workflow'
|
||||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
uses: './.audio-workflow/.github/actions/self-hosted-node'
|
||||
- name: 'Setup Node.js'
|
||||
if: "${{ runner.environment != 'self-hosted' }}"
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version: '22.22.3'
|
||||
- name: 'Validate complete audio prebuilds'
|
||||
working-directory: '.'
|
||||
run: 'node .audio-workflow/.github/scripts/audio-prebuilds.mjs validate packages/audio-capture/prebuilds'
|
||||
- name: 'Upload combined prebuilds'
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'audio-capture-prebuilds'
|
||||
overwrite: true
|
||||
path: 'packages/audio-capture/prebuilds/'
|
||||
if-no-files-found: 'error'
|
||||
|
||||
- name: 'Retain reusable audio prebuilds'
|
||||
if: "${{ inputs.cache_key != '' }}"
|
||||
continue-on-error: true
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'audio-capture-prebuilds-${{ inputs.cache_key }}'
|
||||
path: 'packages/audio-capture/prebuilds/'
|
||||
if-no-files-found: 'error'
|
||||
overwrite: true
|
||||
@@ -21,7 +21,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-push-to-ghcr:
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
packages: 'write'
|
||||
@@ -43,8 +43,11 @@ jobs:
|
||||
# on a publishing run is exactly a run the reporting job must cover.
|
||||
- name: 'Export the publish decision'
|
||||
id: 'publish-decision'
|
||||
working-directory: '${{ runner.temp }}'
|
||||
run: 'echo "push_image=${PUSH_IMAGE}" >> "$GITHUB_OUTPUT"'
|
||||
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
|
||||
@@ -31,7 +31,7 @@ on:
|
||||
version:
|
||||
description: 'Version to release (without v prefix)'
|
||||
required: true
|
||||
default: '0.26.0'
|
||||
default: '0.27.0'
|
||||
notarize:
|
||||
description:
|
||||
'Codesign + notarize the macOS artifacts (false to skip during
|
||||
|
||||
+185
-38
@@ -114,18 +114,20 @@ jobs:
|
||||
classify_pr:
|
||||
name: 'Classify PR'
|
||||
if: "${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}"
|
||||
# Gate runs on ECS for in-repo PRs, fork PRs whose author has write access
|
||||
# (OWNER/MEMBER/COLLABORATOR association — a write-access author is as
|
||||
# trusted as an in-repo branch), the merge queue, and pushes to main,
|
||||
# else a busy hosted pool delays it and blocks the ECS-bound jobs. The
|
||||
# kill-switch is read here, so flipping it reverts everything to hosted.
|
||||
# This runs-on and the pick_runner step below are the canonical home of
|
||||
# the association routing; sdk-java.yml and serve-ab.yml mirror it (the
|
||||
# routing tests hold the mirrors to it). test_windows deliberately does
|
||||
# not: a pull_request run executes the PR's own YAML, so no runs-on trust
|
||||
# clause is enforceable there and its lane never admits pull requests to
|
||||
# the pool.
|
||||
runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association) || github.event_name == ''merge_group'' || github.event_name == ''push'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
# Gate runs on ECS for EVERY event it accepts — pull requests (untrusted
|
||||
# forks included), merge queue, pushes to main, and dispatch — unless the
|
||||
# kill-switch forces hosted. classify executes only trusted base-SHA code
|
||||
# with a read-only token; the residual PR-YAML risk is the same class
|
||||
# accepted for lint_and_static (2026-10-31 decision, see
|
||||
# docs/design/2026-10-31-lint-static-ecs-routing.md). Keeping the gate on
|
||||
# hosted for forks queued the entire fork CI chain behind a scarce pool.
|
||||
# The association routing for the downstream lanes lives in pick_runner
|
||||
# (ubuntu_runner) below; sdk-java.yml and serve-ab.yml mirror that form
|
||||
# (the routing tests hold the mirrors to it). test_windows deliberately
|
||||
# does not: a pull_request run executes the PR's own YAML, so no runs-on
|
||||
# trust clause is enforceable there and its lane never admits pull
|
||||
# requests to the pool.
|
||||
runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: 'read'
|
||||
@@ -133,6 +135,12 @@ jobs:
|
||||
outputs:
|
||||
skip_ci: '${{ steps.release_sync.outputs.skip_ci }}'
|
||||
ubuntu_runner: '${{ steps.pick_runner.outputs.ubuntu_runner }}'
|
||||
# Lint & Static's own routing: every pull request reaches the fleet,
|
||||
# including untrusted forks (maintainer decision 2026-10-31 — the lane
|
||||
# executes PR code on the shared pool and that risk is accepted). The
|
||||
# association gate in ubuntu_runner still governs every other Linux
|
||||
# lane; do not "simplify" the two outputs into one.
|
||||
lint_runner: '${{ steps.pick_runner.outputs.lint_runner }}'
|
||||
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
|
||||
steps:
|
||||
- name: 'Detect release version-sync PR'
|
||||
@@ -179,6 +187,12 @@ jobs:
|
||||
#
|
||||
# Keep push routing aligned with the classifier above so a hosted queue
|
||||
# does not delay the downstream ECS jobs.
|
||||
#
|
||||
# lint_runner is the one deliberate divergence: Lint & Static routes
|
||||
# EVERY pull request to the fleet, untrusted forks included (maintainer
|
||||
# decision 2026-10-31, risk accepted — see the classify_pr outputs
|
||||
# comment). The kill-switch and LINUX_RUNNER_FLEET=hosted still force
|
||||
# hosted, and non-PR events inherit ubuntu_runner unchanged.
|
||||
- name: 'Select Linux runner'
|
||||
id: 'pick_runner'
|
||||
env:
|
||||
@@ -197,6 +211,11 @@ jobs:
|
||||
case "${AUTHOR_ASSOCIATION}" in
|
||||
OWNER|MEMBER|COLLABORATOR) trusted_author=true ;;
|
||||
esac
|
||||
# Resolve fleet labels once; both outputs assign from this.
|
||||
fleet_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
||||
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
|
||||
fleet_runner='["self-hosted", "linux", "arm64"]'
|
||||
fi
|
||||
# Dispatch input wins over the fleet variable; MAINTAINER_ECS_RUNNER_
|
||||
# DISABLED=true still hard-forces hosted runners everywhere.
|
||||
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
@@ -211,14 +230,18 @@ jobs:
|
||||
elif [[ "${ECS_DISABLED}" == "true" || "${LINUX_RUNNER_FLEET}" == "hosted" ]]; then
|
||||
ubuntu_runner='["ubuntu-latest"]'
|
||||
elif [[ "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" || "${EVENT_NAME}" == "push" ]]; then
|
||||
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
|
||||
ubuntu_runner='["self-hosted", "linux", "arm64"]'
|
||||
else
|
||||
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
||||
fi
|
||||
ubuntu_runner="${fleet_runner}"
|
||||
fi
|
||||
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Selected Linux runner: ${ubuntu_runner}"
|
||||
# Lint & Static routes every pull request to the fleet (maintainer
|
||||
# decision 2026-10-31); non-PR events inherit ubuntu_runner unchanged.
|
||||
lint_runner="${ubuntu_runner}"
|
||||
if [[ "${EVENT_NAME}" == "pull_request" && "${ECS_DISABLED}" != "true" && "${LINUX_RUNNER_FLEET}" != "hosted" ]]; then
|
||||
lint_runner="${fleet_runner}"
|
||||
fi
|
||||
echo "lint_runner=${lint_runner}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Selected lint runner: ${lint_runner}"
|
||||
|
||||
# The product jobs later check out the PR head. Classify before that
|
||||
# from an explicit base-SHA checkout so no fork can replace the
|
||||
@@ -634,7 +657,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
# The test step's side lane (.github/scripts/ci/run-unit-test-lanes.sh)
|
||||
@@ -741,6 +764,41 @@ jobs:
|
||||
echo '::warning::tmux/zip unavailable; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
|
||||
fi
|
||||
|
||||
- name: 'Provision linters for real-binary tests'
|
||||
# The script-lint real-binary regressions (the #15974 unloadable
|
||||
# workflow, the PR-planted ignore-all config, the merge-base trust
|
||||
# boundary) are skipIf-gated on actionlint/shellcheck being on PATH;
|
||||
# without this step they silently skip on every CI run (PR #16001
|
||||
# review). Same pinned, SHA-256-verified installer the lint lane
|
||||
# uses, promoted onto GITHUB_PATH ahead of anything a shared runner
|
||||
# left there. Advisory: a failed install degrades to the skips, not
|
||||
# a red required check. Windows is excluded — the installer ships
|
||||
# linux/darwin archives only.
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.os != 'Windows' }}"
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
run: |-
|
||||
node --input-type=module -e '
|
||||
import { execSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { getLinters, getLinterTempDir } from "./scripts/lint.js";
|
||||
const tempDir = getLinterTempDir();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const linters = getLinters();
|
||||
for (const name of ["actionlint", "shellcheck"]) {
|
||||
execSync(linters[name].installer, { stdio: "inherit" });
|
||||
// Verify the install produced its binary BEFORE promoting the
|
||||
// dir: a silent no-op install must fail this step visibly
|
||||
// (continue-on-error tolerates it, the UI shows it), not
|
||||
// degrade to skipped tests nobody notices (PR #16001 F2).
|
||||
const bin = tempDir + "/" + name + "/" + name;
|
||||
if (!existsSync(bin)) throw new Error(name + " installer produced no binary at " + bin);
|
||||
appendFileSync(process.env.GITHUB_PATH, tempDir + "/" + name + "\n");
|
||||
}
|
||||
console.log("PROVISIONED actionlint + shellcheck into " + tempDir);
|
||||
'
|
||||
|
||||
- name: 'Run tests and generate reports'
|
||||
id: 'unit_tests'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
@@ -944,8 +1002,14 @@ jobs:
|
||||
# same release-sync convention: stay running on skip_ci PRs so a required
|
||||
# context still reports, with the per-step guards making it a no-op pass.
|
||||
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||||
# Routed like test's, not a bare constant: this lane was beheaded twice
|
||||
# Reads lint_runner, NOT ubuntu_runner: this lane routes every pull
|
||||
# request to the fleet, untrusted forks included (maintainer decision
|
||||
# 2026-10-31 — the lane executes PR code and the shared-pool risk is
|
||||
# accepted). test and the integration lanes keep the association-gated
|
||||
# ubuntu_runner. The kill-switch and hosted-fleet overrides still apply,
|
||||
# inside pick_runner.
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
|
||||
# Pool-scoped like test's, not a bare constant: this lane was beheaded twice
|
||||
# running honestly on cold-cache hk3-pool runners — at 30 minutes flat
|
||||
# (install 892s vs the sibling Test job's 347s) and again at 45 (install
|
||||
# 940s, bundle closure 681s vs a warm 171s, every step 2–4x). The pool's
|
||||
@@ -955,13 +1019,30 @@ jobs:
|
||||
# Keyed on `self-hosted`, not on the x64 fleet's `ecs-qwen` label: the
|
||||
# ARM64 fleet renders ["self-hosted", "linux", "arm64"], so a
|
||||
# fleet-specific substring priced those reserved machines as hosted.
|
||||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
|
||||
# Reads the same lint_runner as runs-on so the ceiling always prices the
|
||||
# pool the job actually lands on.
|
||||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
|
||||
env:
|
||||
LINT_BASE_SHA: "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'read'
|
||||
steps:
|
||||
# Untrusted fork code runs here; job env cannot read the runner context
|
||||
# and RUNNER_TEMP survives across jobs, so publish a run-unique HOME via
|
||||
# GITHUB_ENV. Rationale: docs/design/2026-10-31-lint-static-ecs-routing.md.
|
||||
- name: 'Create isolated HOME'
|
||||
run: |-
|
||||
home="${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
rm -rf -- "$home"
|
||||
mkdir -p "$home" "${RUNNER_TOOL_CACHE}/qwen-ci-cache"
|
||||
printf 'HOME=%s\nUSERPROFILE=%s\n' "$home" "$home" >> "${GITHUB_ENV}"
|
||||
# Linter archives are SHA-256-verified on every cache hit, so sharing
|
||||
# them is safe; corepack does NOT re-verify extracted entries on hit,
|
||||
# so its home stays per-run (a shared one is a poisoning channel).
|
||||
printf 'XDG_CACHE_HOME=%s\n' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}"
|
||||
printf 'COREPACK_HOME=%s\n' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}"
|
||||
|
||||
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
|
||||
# verify) may leave root-owned, read-only files anywhere in the workspace.
|
||||
# Restore ownership and write permission unconditionally so the checkout
|
||||
@@ -1196,7 +1277,7 @@ jobs:
|
||||
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
DOCUMENT='packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json'
|
||||
DOCUMENT='packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json'
|
||||
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[] | .filename, .previous_filename // empty' > "${RUNNER_TEMP}/contract-pr-files.txt"
|
||||
if ! grep -qxF "${DOCUMENT}" "${RUNNER_TEMP}/contract-pr-files.txt"; then
|
||||
exit 0
|
||||
@@ -1204,7 +1285,7 @@ jobs:
|
||||
BASE_DOCUMENT="${RUNNER_TEMP}/contract-base.json"
|
||||
git fetch --quiet --depth=1 --filter=blob:none origin "refs/heads/${BASE_REF}"
|
||||
if ! git show "FETCH_HEAD:${DOCUMENT}" > "${BASE_DOCUMENT}" 2>/dev/null; then
|
||||
PREVIOUS_DOCUMENT="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[] | select(.filename == "packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json") | .previous_filename // empty')"
|
||||
PREVIOUS_DOCUMENT="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[] | select(.filename == "packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json") | .previous_filename // empty')"
|
||||
if [ -n "${PREVIOUS_DOCUMENT}" ]; then
|
||||
git show "FETCH_HEAD:${PREVIOUS_DOCUMENT}" > "${BASE_DOCUMENT}"
|
||||
else
|
||||
@@ -1300,7 +1381,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
@@ -1476,11 +1557,31 @@ jobs:
|
||||
- name: 'Run .github/scripts helper tests'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
run: |-
|
||||
# Route os.tmpdir() off the host /tmp tmpfs: its inode quota is
|
||||
# small and shared across the pool, and e2e-build.test.mjs creates
|
||||
# thousands of chunk files there (#15965 ENOSPC with 202G free).
|
||||
# Same routing as the unit lanes (#10035), private per-run dir
|
||||
# included: RUNNER_TEMP is shared, and a cancelled battery never
|
||||
# reaches its own after() cleanup, so the fixture tree would stick
|
||||
# around in _work/_temp. A hard kill stays uncovered either way.
|
||||
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||
HELPER_TMPDIR="$(mktemp -d "${TMPDIR}/helper-tests-XXXXXX" 2>/dev/null)" || HELPER_TMPDIR=""
|
||||
if [ -n "$HELPER_TMPDIR" ]; then
|
||||
TMPDIR="$HELPER_TMPDIR"
|
||||
export TMPDIR
|
||||
trap 'rm -rf "$HELPER_TMPDIR"' EXIT
|
||||
fi
|
||||
node --test --test-concurrency=1 ${{ env.HELPER_TESTS }} 2>&1 | tee "${RUNNER_TEMP}/helper-attempt1.log" || {
|
||||
node --test --test-concurrency=1 ${{ env.HELPER_TESTS }} &&
|
||||
echo "::warning::helper tests failed once and the retry absorbed it; attempt-1 failures: $(grep -E '^not ok' "${RUNNER_TEMP}/helper-attempt1.log" | head -5 | paste -sd ';' -) — count this toward the #12772 helper-battery recurrence threshold"
|
||||
}
|
||||
|
||||
# Reclaim the run-unique HOME (constructed path, never $HOME) before the
|
||||
# failure collector, which must stay last.
|
||||
- name: 'Clean isolated HOME'
|
||||
if: '${{ always() }}'
|
||||
run: 'rm -rf -- "${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"'
|
||||
|
||||
# State-at-failure dump for the 19 substantive steps after install: the
|
||||
# install sampler is reaped by that step's `trap … EXIT`, so a death in
|
||||
# ESLint or the bundle-closure build would otherwise upload an artifact
|
||||
@@ -1701,7 +1802,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
@@ -1778,9 +1879,15 @@ jobs:
|
||||
echo "Using web-shell Playwright port ${port}"
|
||||
|
||||
- name: 'Run transcript document browser gate'
|
||||
env:
|
||||
# Avoid the pool's shared /tmp tmpfs for Chromium temporary files.
|
||||
TMPDIR: '${{ runner.temp }}'
|
||||
run: 'npx vitest run --root ./integration-tests ./chat-transcript-document.test.ts --retry=0'
|
||||
|
||||
- name: 'Run web-shell browser smoke'
|
||||
env:
|
||||
# Avoid the pool's shared /tmp tmpfs for Chromium temporary files.
|
||||
TMPDIR: '${{ runner.temp }}'
|
||||
# Two workers is the hosted default (half of 4 vCPUs). Unpinned, a pool
|
||||
# job would start browsers for half the host's cores while the host
|
||||
# runs its other jobs.
|
||||
@@ -1837,14 +1944,32 @@ jobs:
|
||||
# is most of the traffic here and most of what the macOS queue was spent
|
||||
# on.
|
||||
#
|
||||
# Two named jobs, not a matrix: a skipped matrix job reports one collapsed
|
||||
# check name, never the per-OS contexts, so a required-check configuration
|
||||
# would sit "Expected" forever. A skipped named job reports under its exact
|
||||
# name. (No status check is required on `main` today — the ruleset carries
|
||||
# only deletion, non-fast-forward and pull_request rules — so this shape is
|
||||
# currently insurance, not a live constraint.)
|
||||
# Preserve the original macOS check name while running its complete suites
|
||||
# in bounded groups. The aggregate rejects failed, skipped or cancelled
|
||||
# groups, including when one group fails before the others finish.
|
||||
test_macos:
|
||||
name: 'Test (macos-latest, Node 22.x)'
|
||||
needs: 'test_macos_shards'
|
||||
if: |-
|
||||
${{
|
||||
always() && (
|
||||
github.event_name == 'merge_group' ||
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
)
|
||||
}}
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: 'read'
|
||||
steps:
|
||||
- name: 'Require every macOS test group to pass'
|
||||
env:
|
||||
MACOS_RESULT: '${{ needs.test_macos_shards.result }}'
|
||||
run: 'test "$MACOS_RESULT" = success'
|
||||
|
||||
test_macos_shards:
|
||||
name: 'macOS unit tests (${{ matrix.group }} ${{ matrix.shard }})'
|
||||
needs: 'classify_pr'
|
||||
if: |-
|
||||
${{
|
||||
@@ -1854,6 +1979,24 @@ jobs:
|
||||
github.event_name == 'workflow_dispatch'
|
||||
)
|
||||
}}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: 'cli'
|
||||
shard: '1/3'
|
||||
- group: 'cli'
|
||||
shard: '2/3'
|
||||
- group: 'cli'
|
||||
shard: '3/3'
|
||||
- group: 'core'
|
||||
shard: '1/2'
|
||||
- group: 'core'
|
||||
shard: '2/2'
|
||||
- group: 'rest'
|
||||
shard: ''
|
||||
- group: 'scripts'
|
||||
shard: ''
|
||||
runs-on: 'macos-latest'
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
@@ -1889,19 +2032,22 @@ jobs:
|
||||
npm config set fetch-timeout 300000
|
||||
|
||||
- name: 'Install dependencies'
|
||||
id: 'install'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
run: |-
|
||||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||||
|
||||
- name: 'Hosted portable process smoke'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && matrix.group == 'scripts' }}"
|
||||
timeout-minutes: 5
|
||||
run: |-
|
||||
npm run test:integration:hosted:sandbox:none -- -t 'portable startup'
|
||||
|
||||
- name: 'Run tests and generate reports'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
if: "${{ !cancelled() && steps.install.outcome == 'success' && needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
env:
|
||||
MACOS_TEST_GROUP: '${{ matrix.group }}'
|
||||
MACOS_TEST_SHARD: '${{ matrix.shard }}'
|
||||
NO_COLOR: true
|
||||
HOME: '${{ runner.temp }}/qwen-ci-home'
|
||||
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
||||
@@ -1930,7 +2076,7 @@ jobs:
|
||||
( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
|
||||
SAMPLER_PID=$!
|
||||
set +e
|
||||
npm run test:ci
|
||||
node scripts/run-macos-ci-group.js "$MACOS_TEST_GROUP" "$MACOS_TEST_SHARD"
|
||||
RC=$?
|
||||
set -e
|
||||
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
|
||||
@@ -2058,7 +2204,7 @@ jobs:
|
||||
# so create the directory through the Node the preflight verified.
|
||||
node -e "require('node:fs').mkdirSync(process.argv[1], { recursive: true })" "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
@@ -2172,7 +2318,8 @@ jobs:
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||||
# Dependency installation alone can exceed 30 minutes on a contended shared
|
||||
# ECS host, before the separately bounded 20-minute integration step starts.
|
||||
# Same self-hosted scoping as `lint_and_static`: hosted fallback routing
|
||||
# Same self-hosted scoping as `test` (this lane keeps the association-gated
|
||||
# ubuntu_runner, unlike lint_and_static): hosted fallback routing
|
||||
# keeps the pre-contention ceiling, and the ARM64 fleet — which carries
|
||||
# no `ecs-qwen` label — gets the contended-pool allowance it runs under.
|
||||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''60'' || ''30'') }}'
|
||||
@@ -2334,7 +2481,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Disk floor gate (self-hosted)'
|
||||
@@ -2561,7 +2708,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
# Same pre-install admission check as the test job (#10035): this job
|
||||
|
||||
@@ -772,7 +772,7 @@ jobs:
|
||||
- 'prepare'
|
||||
- 'desktop_shell'
|
||||
- 'build'
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
timeout-minutes: 20
|
||||
# The feed move below overwrites desktop-latest.json with --clobber, so it
|
||||
# sits behind the same deployment gate as the OSS mirror job.
|
||||
@@ -781,6 +781,8 @@ jobs:
|
||||
permissions:
|
||||
contents: 'write'
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ needs.prepare.outputs.qwen_code_sha }}'
|
||||
|
||||
@@ -32,31 +32,73 @@ jobs:
|
||||
# pool host, so the six Linux shards spent more of the shared pool
|
||||
# rebuilding one commit than testing it. The outputs are platform-
|
||||
# independent JavaScript (native modules come from each leg's own
|
||||
# `npm ci`), so a hosted VM builds once, off the pool, and every leg
|
||||
# downloads the result. See .github/scripts/e2e-build-pack.sh.
|
||||
# `npm ci`), so one job builds once and every leg downloads the result.
|
||||
# The build shares the legs' pool-first routing: hosted ubuntu jobs sat
|
||||
# queued for hours while the pool idled (2026-10-28), and one build per
|
||||
# push adds no measurable pool load. The OpenTUI interactive leg is still
|
||||
# hosted-pinned, so a run can still wait on hosted capacity for that leg —
|
||||
# pool routing takes the wait off the critical first step, it does not
|
||||
# remove every hosted dependency. See .github/scripts/e2e-build-pack.sh.
|
||||
build:
|
||||
name: 'Build for E2E'
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
timeout-minutes: 30
|
||||
# Same fork gate as the legs, so a skipped build skips them all for the
|
||||
# same reason rather than failing them on a missing artifact.
|
||||
if: |-
|
||||
${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
steps:
|
||||
- name: 'Restore workspace ownership'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
RUNNER_UID="$(id -u)"
|
||||
RUNNER_GID="$(id -g)"
|
||||
if [ "$RUNNER_UID" != "0" ]; then
|
||||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||||
# The docker leg's host-side locks live in the runner's home: a
|
||||
# root-owned leftover under ~/.cache/qwen-code-ci fails every
|
||||
# `exec 9>` against them with EACCES before a test runs (#11990).
|
||||
if [ -d "${HOME}/.cache/qwen-code-ci" ]; then
|
||||
chown -R "$RUNNER_UID:$RUNNER_GID" "${HOME}/.cache/qwen-code-ci" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "${HOME}/.cache/qwen-code-ci" || echo "::warning::could not restore CI cache ownership; docker sandbox locks may stay unreadable"
|
||||
fi
|
||||
fi
|
||||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
# Hosted-only, mirroring the legs: on the pool, nodejs.org may be
|
||||
# unreachable through the egress proxy, and the action's post step
|
||||
# uploads the npm cache to GitHub — a save that ran 14+ minutes over
|
||||
# the pool's slow egress on security-checks' first pool run
|
||||
# (2026-08-26). The pool reuses the machine's Node and its persistent
|
||||
# npm cache instead.
|
||||
- name: 'Set up Node.js (hosted)'
|
||||
if: "${{ runner.environment == 'github-hosted' }}"
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
package-manager-cache: false
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
uses: './.github/actions/self-hosted-node'
|
||||
|
||||
- name: 'Cache pnpm store (hosted)'
|
||||
if: "${{ runner.environment == 'github-hosted' }}"
|
||||
uses: './.github/actions/pnpm-store-cache'
|
||||
|
||||
- name: 'Configure persistent npm cache (self-hosted)'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
run: |-
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
run: |-
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
@@ -64,6 +106,10 @@ jobs:
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-timeout 300000
|
||||
|
||||
- name: 'Disk floor gate (self-hosted)'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
|
||||
|
||||
# A bare `corepack pnpm install` has no retry: run 34700339334's
|
||||
# build leg died at this step before any test ran, on a tree whose
|
||||
# install reproduces clean, and every leg behind `needs: [build]`
|
||||
@@ -339,6 +385,7 @@ jobs:
|
||||
- name: 'Download build artifact (retry)'
|
||||
if: "${{ steps.download-build.outcome == 'failure' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
name: 'e2e-build'
|
||||
path: '${{ runner.temp }}/e2e-build'
|
||||
@@ -419,6 +466,7 @@ jobs:
|
||||
e2e-test-macos:
|
||||
name: 'E2E Test - macOS - shard ${{ matrix.shard }}'
|
||||
runs-on: 'macos-latest'
|
||||
timeout-minutes: 60
|
||||
needs:
|
||||
- 'build'
|
||||
# Skip on fork PRs (no secrets) — see e2e-test-linux above.
|
||||
@@ -429,9 +477,14 @@ jobs:
|
||||
matrix:
|
||||
# Two shards, not three: the macOS runner is the slowest to install and
|
||||
# build, so a third shard would add more fixed cost than it removes.
|
||||
shard:
|
||||
- '1/2'
|
||||
- '2/2'
|
||||
# Exception: on main pushes the org-level macos-latest pool is so
|
||||
# contended that the two shards serialize anyway (#15052 measured
|
||||
# 2.4–6h waits per shard, with runs cancelled after five legs had
|
||||
# already gone green). One unsharded leg keeps full coverage at the
|
||||
# same runner-minute cost and halves slot acquisitions per push.
|
||||
shard: >-
|
||||
${{ github.event_name == 'push' && github.ref_name == 'main'
|
||||
&& fromJSON('["1/1"]') || fromJSON('["1/2","2/2"]') }}
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
@@ -503,6 +556,7 @@ jobs:
|
||||
- name: 'Download build artifact (retry)'
|
||||
if: "${{ steps.download-build.outcome == 'failure' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
name: 'e2e-build'
|
||||
path: '${{ runner.temp }}/e2e-build'
|
||||
@@ -620,6 +674,7 @@ jobs:
|
||||
- name: 'Download build artifact (retry)'
|
||||
if: "${{ steps.download-build.outcome == 'failure' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
name: 'e2e-build'
|
||||
path: '${{ runner.temp }}/e2e-build'
|
||||
@@ -648,6 +703,7 @@ jobs:
|
||||
isolated-nightly:
|
||||
name: '${{ matrix.label }} (nightly)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 60
|
||||
needs:
|
||||
- 'build'
|
||||
if: |-
|
||||
@@ -734,6 +790,7 @@ jobs:
|
||||
- name: 'Download build artifact (retry)'
|
||||
if: "${{ steps.download-build.outcome == 'failure' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
name: 'e2e-build'
|
||||
path: '${{ runner.temp }}/e2e-build'
|
||||
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
finalize:
|
||||
name: 'Finalize Stable Release'
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' && (github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false) }}
|
||||
environment:
|
||||
@@ -29,6 +29,8 @@ jobs:
|
||||
RELEASE_TAG: '${{ github.event.release.tag_name || inputs.tag }}'
|
||||
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Validate stable release tag'
|
||||
id: 'meta'
|
||||
env:
|
||||
@@ -57,7 +59,7 @@ jobs:
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
if: |-
|
||||
${{ steps.meta.outputs.is_stable == 'true' }}
|
||||
${{ runner.environment != 'self-hosted' && steps.meta.outputs.is_stable == 'true' }}
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
@@ -298,7 +298,10 @@ jobs:
|
||||
# Both bodies read this list; the flag lets them say when it is short.
|
||||
echo "jobs_complete=${jobs_complete}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
mapfile -t job_ids < <(
|
||||
job_ids=()
|
||||
while IFS= read -r job_id; do
|
||||
job_ids+=("${job_id}")
|
||||
done < <(
|
||||
jq -r '.jobs[] | select(.conclusion == "failure") | .id' \
|
||||
"${jobs_json}" 2>/dev/null
|
||||
)
|
||||
@@ -316,7 +319,7 @@ jobs:
|
||||
# every download fails. Logs feed analyze, whose tests populate the
|
||||
# comment renderer's Failing tests section; without them the summary
|
||||
# degrades to job/step names. The analyzer strips ANSI before matching.
|
||||
for job_id in "${job_ids[@]}"; do
|
||||
for job_id in ${job_ids[@]+"${job_ids[@]}"}; do
|
||||
if ! gh api "repos/${REPO}/actions/jobs/${job_id}/logs" \
|
||||
--allow-escape-sequences > "${log_dir}/${job_id}.log"; then
|
||||
# A missing log costs test details, not the job/step summary.
|
||||
@@ -369,14 +372,14 @@ jobs:
|
||||
echo "Failed job ${job_id} carries no infrastructure annotation; the run gets an issue."
|
||||
fi
|
||||
done < <(
|
||||
# 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
|
||||
# The gate reporter and macOS aggregate mirror lane failures
|
||||
# without an infrastructure signature of their own; excluding
|
||||
# them by their 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.
|
||||
jq -r '.jobs[] | select(.conclusion == "failure") | select(([(.steps // [])[] | select(.conclusion == "failure") | .name] | index("Report the tier result")) | not) | [(.id | tostring), ((.check_run_url // "") | split("/")[-1])] | @tsv' \
|
||||
jq -r '.jobs[] | select(.conclusion == "failure") | select(([(.steps // [])[] | select(.conclusion == "failure") | .name] | map(select(. == "Report the tier result" or . == "Require every macOS test group to pass")) | length) == 0) | [(.id | tostring), ((.check_run_url // "") | split("/")[-1])] | @tsv' \
|
||||
"${jobs_json}" 2>/dev/null
|
||||
)
|
||||
if [[ "${total}" -gt 0 && "${infra}" -eq "${total}" ]]; then
|
||||
@@ -591,7 +594,7 @@ jobs:
|
||||
existing_issue=''
|
||||
matched_body=''
|
||||
signature_unresolved=false
|
||||
for family_line in "${family[@]}"; do
|
||||
for family_line in ${family[@]+"${family[@]}"}; do
|
||||
candidate="${family_line%%$'\t'*}"
|
||||
candidate_body="${RUNNER_TEMP}/candidate-${candidate}.md"
|
||||
if ! gh issue view "${candidate}" --repo "${REPO}" \
|
||||
@@ -719,7 +722,7 @@ jobs:
|
||||
siblings_ok=true
|
||||
siblings_corpus="${RUNNER_TEMP}/sibling-bodies.md"
|
||||
: > "${siblings_corpus}"
|
||||
for family_line in "${family[@]}"; do
|
||||
for family_line in ${family[@]+"${family[@]}"}; do
|
||||
sibling="${family_line%%$'\t'*}"
|
||||
[[ "${sibling}" == "${existing_issue}" ]] && continue
|
||||
sibling_body="${RUNNER_TEMP}/candidate-${sibling}.md"
|
||||
@@ -828,13 +831,13 @@ jobs:
|
||||
# enough: a human can retitle a per-signature issue to the exact
|
||||
# shape, so every candidate passes the same body screen in order.
|
||||
family_ordered=()
|
||||
for family_line in "${family[@]}"; do
|
||||
for family_line in ${family[@]+"${family[@]}"}; do
|
||||
[[ "${family_line#*$'\t'}" == exact ]] && family_ordered+=("${family_line}")
|
||||
done
|
||||
for family_line in "${family[@]}"; do
|
||||
for family_line in ${family[@]+"${family[@]}"}; do
|
||||
[[ "${family_line#*$'\t'}" == exact ]] || family_ordered+=("${family_line}")
|
||||
done
|
||||
for family_line in "${family_ordered[@]}"; do
|
||||
for family_line in ${family_ordered[@]+"${family_ordered[@]}"}; do
|
||||
candidate="${family_line%%$'\t'*}"
|
||||
candidate_body="${RUNNER_TEMP}/family-${candidate}.md"
|
||||
if ! gh issue view "${candidate}" --repo "${REPO}" \
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# Round seeding — `@qwen-code /takeover from N`
|
||||
# Parameterized takeover commands
|
||||
|
||||
Operator guide for the one parameterized takeover command. For the
|
||||
implementation rationale behind each gate, see the design record:
|
||||
Operator guide for the two parameterized takeover forms — `from N` (round seed,
|
||||
below) and `for N` (round budget, further down). For the implementation
|
||||
rationale behind each gate, see the design record:
|
||||
[`qwen-autofix.md`](./qwen-autofix.md) — [af-007](./qwen-autofix.md#af-007)
|
||||
(the parser) and [af-016](./qwen-autofix.md#af-016) (the marker).
|
||||
(the parser), [af-016](./qwen-autofix.md#af-016) (the seed marker) and
|
||||
[af-159](./qwen-autofix.md#af-159) (the budget).
|
||||
|
||||
## The problem it solves
|
||||
## Round seed — `@qwen-code /takeover from N`
|
||||
|
||||
### The problem it solves
|
||||
|
||||
The autofix loop stops implementing non-Critical feedback once a PR has
|
||||
completed `CRITICAL_ONLY_AFTER_ROUND` (5) change-producing rounds. From there
|
||||
@@ -22,7 +26,7 @@ diff on nice-to-haves at exactly the point it should have been converging.
|
||||
`from N` lets you state what the loop cannot infer: how much review this PR has
|
||||
already been through.
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
```
|
||||
@qwen-code /takeover from 4
|
||||
@@ -49,7 +53,7 @@ The same refusals apply — a non-`main` base, `autofix/skip`, a fork without
|
||||
maintainer-edit access, and a fork whose author lacks write+ on this repository
|
||||
are all declined out loud.
|
||||
|
||||
### Picking N
|
||||
#### Picking N (seed)
|
||||
|
||||
N is "how many review rounds has this PR already had", counted the way the loop
|
||||
counts: **rounds that produced changes**, not individual comments or reviews. A
|
||||
@@ -70,7 +74,7 @@ seed.
|
||||
| `from 4` | 4 | 1 |
|
||||
| `from 5` | 5 | 0 — Critical-only from the first managed round |
|
||||
|
||||
## Semantics
|
||||
### Semantics
|
||||
|
||||
**The seed is a floor for an empty window, not an offset added to every round.**
|
||||
The counter is `max(this window's round markers)`, falling back to the seed when
|
||||
@@ -96,7 +100,7 @@ the loop instead of starting it — so it is clamped to `cap - 1`. When that
|
||||
happens, the Critical-only audit record cites the number you **typed**, plus a
|
||||
note naming the clamp, so it never quotes a command nobody sent.
|
||||
|
||||
## What it does not do
|
||||
### What it does not do
|
||||
|
||||
**It does not seed the growth brake.** `CRITICAL_ONLY_AFTER_ROUND` is only one
|
||||
of two brakes; the other trips when the diff grows past
|
||||
@@ -112,34 +116,148 @@ base-conflict resolution all keep flowing. Only the suggestion channel stops.
|
||||
**It does not shorten the round cap in any meaningful way.** Under takeover the
|
||||
cap is 100, so a seed of 4 leaves 96.
|
||||
|
||||
## Round budget — `@qwen-code /takeover for N`
|
||||
|
||||
The bounded opposite of the seed. `from N` decides how much _suggestion_ budget
|
||||
this window has; `for N` decides how many _rounds_ the loop may run at all.
|
||||
|
||||
```
|
||||
@qwen-code /takeover for 2
|
||||
```
|
||||
|
||||
Engages takeover and records a window round budget of 2 on the engage ack:
|
||||
|
||||
```
|
||||
<!-- takeover-ack engaged -->
|
||||
<!-- autofix-round-budget 2 -->
|
||||
```
|
||||
|
||||
The effective cap for this window becomes `N` (it only ever lowers the takeover
|
||||
cap of 100). When the counter reaches `N`, the loop **releases** the PR:
|
||||
|
||||
- it posts one bilingual "budget spent — released" note, per window;
|
||||
- it removes `autofix/takeover`;
|
||||
- it does **not** apply `autofix/needs-human` and does **not** post the paused
|
||||
"reached its round cap" notice — a finished budget leaves nothing stuck, so
|
||||
both would be false.
|
||||
|
||||
**A forced dispatch defers that release to the next non-forced scan.** If the
|
||||
fleet shepherd dispatches a conflict round at the moment the budget finishes,
|
||||
that pass has to answer the dispatch out loud, so it takes the paused path
|
||||
(paused notice plus `autofix/needs-human`) and the next scheduled scan releases
|
||||
— clearing that escalation label with it. The notices on the deferred pass
|
||||
quote the attempts spent (N/N), not the change-producing round counter, which a
|
||||
no-op-only engagement leaves at 0.
|
||||
|
||||
**Every finished attempt counts.** The budget is measured by the round's eval
|
||||
marker, one per attempt: a no-op round writes one with its round number
|
||||
unchanged, and a round that is cancelled or dies in setup writes one with the
|
||||
round advanced by one and a sentinel timestamp. So a PR whose rounds keep
|
||||
finding nothing still ends after N — that is the case a round counter cannot
|
||||
express. The guarantee is one attempt per marker, and it assumes the marker
|
||||
lands: the report step retries its post, but a comment-API outage that outlasts
|
||||
those retries leaves a round uncounted, which delays the release rather than
|
||||
ending it early.
|
||||
|
||||
**A crash or a timeout spends its attempt; a breaker parks.** A crashed or
|
||||
timed-out round stamps the sentinel timestamp and only advances the round by
|
||||
one, so it consumes an attempt and nothing else — one such round does not stop
|
||||
anything. A breaker withholds the release (`autofix/needs-human` plus the paused
|
||||
notice) when the engagement holds `TIMEOUT_WINDOW_CAP` (3) sentinel-stamped
|
||||
rounds with silent-sandbox (idle) ones subtracted, when a round is past
|
||||
`ROUND_START + attempts`, or when a convergence-break notice is newer than the
|
||||
window key. Those are the breaker's own thresholds, unchanged by `for N`; read a
|
||||
lasting pause on a budgeted PR as "a human should look", never as "the budget
|
||||
finished" — the one transient pause is the deferred release above.
|
||||
|
||||
**A breaker stop is not a spent budget.** When the consecutive-failure, timeout
|
||||
or convergence breaker stops the loop early, it stamps a terminal sentinel round
|
||||
far past the real attempts and posts no extra marker — so the window is not
|
||||
"spent", and the release note is not posted. A breaker asks for a human.
|
||||
|
||||
**An automatic re-arm rebases the growth window, and the budget rides along.**
|
||||
A growth-audit round appends `autofix-rearm` for its own round accounting, and
|
||||
suppressing it would pin the growth baseline and make every later round
|
||||
Critical-only — so it is unconditional. It is not the only thing that re-anchors
|
||||
that baseline (a base-branch update and the scan's heal ack do too). What keeps
|
||||
the budget honest is its key: the budget and the attempts spent are counted per
|
||||
engagement — a command opens one, that automatic re-arm does not — so the audit
|
||||
rebases while the remaining budget still applies.
|
||||
|
||||
**A queued round from the same window is discarded once the budget is spent.** A
|
||||
no-op leaves the round number UNCHANGED and the window key unchanged, so neither the
|
||||
superseded-window nor the round-cap check would catch a round that was queued
|
||||
before it; the address leg recounts the window's attempts and drops it.
|
||||
|
||||
**The budget is engagement-scoped, not window-scoped.** It is read from every
|
||||
ack or re-arm at or after the anchor that carries it, excluding the automatic
|
||||
growth-audit re-arm (whose comment carries that round's eval marker). So
|
||||
`/retry` or a bare `/takeover` opens a new engagement with no budget (unbounded
|
||||
takeover, as before), and a budgeted re-arm (`@qwen-code /takeover for 3` on an
|
||||
already-managed PR) starts a fresh one at round 0 with a 3-round budget. Round
|
||||
accounting is the read that stays window-keyed.
|
||||
|
||||
**Bot-authored PRs are the one exception.** They are managed without the label,
|
||||
so releasing it would hand them back to standard ten-round management and the
|
||||
budget would be a lie. They land on the existing paused path instead: paused
|
||||
notice plus `autofix/needs-human`, and the fleet shepherd's auto-release ages the
|
||||
label out. The budget stops them at N, and a SPENT one outlives that auto-release
|
||||
(and a hand-removal, which leaves no marker either): the PR stays parked at N/N
|
||||
until a new command moves the engagement. Only an explicit `stop` ends it, which
|
||||
is why its ack says standard management continues.
|
||||
|
||||
**A budgeted re-arm also cancels queued work, conflicts included.** A conflict
|
||||
round is normally exempt from the superseded-window discard because it stays
|
||||
actionable, but a queued job carries the old cap: with a smaller budget it would
|
||||
run past the new limit. The next scan re-selects the conflict under the new
|
||||
window.
|
||||
|
||||
### Picking N
|
||||
|
||||
| You type | Rounds the loop may run | Typical use |
|
||||
| -------- | ----------------------- | --------------------------------------------------- |
|
||||
| `for 1` | 1 | "clear this batch of review comments, then stop" |
|
||||
| `for 2` | 2 | one round plus a follow-up after CI comes back |
|
||||
| `for 5` | 5 | a small, deliberately bounded engagement |
|
||||
| `for 99` | 99 | effectively unbounded; use bare `/takeover` instead |
|
||||
|
||||
`for 0` is **not** accepted: a zero-round engagement is what `/takeover stop`
|
||||
already does, so it fails closed with the rest of the malformed bodies.
|
||||
|
||||
## Accepted and rejected forms
|
||||
|
||||
The literal prefix must match `@qwen-code /takeover` byte-for-byte and the tail
|
||||
must be a bare 1–2 digit integer. The command has to be the very first thing in
|
||||
the comment: **no leading whitespace of any kind** — space, tab, or blank line.
|
||||
must be either `from N` or `for N` with a bare 1–2 digit integer. The command
|
||||
has to be the very first thing in the comment: **no leading whitespace of any
|
||||
kind** — space, tab, or blank line.
|
||||
The router prefilters on the _raw_ comment body with `startsWith`, so a body
|
||||
with leading whitespace never starts a job at all, and the trim
|
||||
that runs inside that job never gets the chance
|
||||
([af-004](./qwen-autofix.md#af-004)). Trailing whitespace is harmless. Anything
|
||||
else fails closed — no label, no seed, no partial effect, and, when the router
|
||||
never started, not even a log line.
|
||||
else fails closed — no label, no seed, no budget, no partial effect, and, when
|
||||
the router never started, not even a log line.
|
||||
|
||||
| Body | Result |
|
||||
| ------------------------------------ | ------------------------------------------- |
|
||||
| `@qwen-code /takeover from 4` | engage, seed 4 |
|
||||
| `@qwen-code /takeover from 04` | engage, seed 4 (read as decimal, not octal) |
|
||||
| `@qwen-code /takeover from 0` | engage, no seed — the explicit spelling |
|
||||
| `@qwen-code /takeover` | engage, no seed |
|
||||
| `@qwen-code /takeover stop` | release |
|
||||
| `@qwen-code /takeover stop from 4` | **nothing** — neither releases nor engages |
|
||||
| `@qwen-code /takeover from 100` | **nothing** — 3 digits rejected |
|
||||
| `@qwen-code /takeover from 4` | **nothing** — double space |
|
||||
| `please @qwen-code /takeover from 4` | **nothing** — must start the comment |
|
||||
| ` @qwen-code /takeover from 4` | **nothing** — leading spaces |
|
||||
| blank line, then the command | **nothing** — leading newline |
|
||||
| `@qwen-code /takeover from 4 please` | **nothing** — must end the comment |
|
||||
| Body | Result |
|
||||
| ------------------------------------ | ----------------------------------------------- |
|
||||
| `@qwen-code /takeover from 4` | engage, seed 4 |
|
||||
| `@qwen-code /takeover from 04` | engage, seed 4 (read as decimal, not octal) |
|
||||
| `@qwen-code /takeover from 0` | engage, no seed — the explicit spelling |
|
||||
| `@qwen-code /takeover for 2` | engage, 2-round budget |
|
||||
| `@qwen-code /takeover for 02` | engage, 2-round budget (decimal, not octal) |
|
||||
| `@qwen-code /takeover` | engage, no seed, no budget |
|
||||
| `@qwen-code /takeover stop` | release |
|
||||
| `@qwen-code /takeover stop from 4` | **nothing** — neither releases nor engages |
|
||||
| `@qwen-code /takeover for 0` | **nothing** — a zero-round engagement is `stop` |
|
||||
| `@qwen-code /takeover from 4 for 2` | **nothing** — the two forms do not combine |
|
||||
| `@qwen-code /takeover from 100` | **nothing** — 3 digits rejected |
|
||||
| `@qwen-code /takeover for 100` | **nothing** — 3 digits rejected |
|
||||
| `@qwen-code /takeover from 4` | **nothing** — double space |
|
||||
| `please @qwen-code /takeover from 4` | **nothing** — must start the comment |
|
||||
| ` @qwen-code /takeover from 4` | **nothing** — leading spaces |
|
||||
| blank line, then the command | **nothing** — leading newline |
|
||||
| `@qwen-code /takeover from 4 please` | **nothing** — must end the comment |
|
||||
|
||||
## Reading the result
|
||||
### Reading the result
|
||||
|
||||
Once the brake engages, the round report carries a `Deferred non-Critical
|
||||
feedback` section whose preamble names the seed explicitly, for example:
|
||||
|
||||
@@ -91,16 +91,20 @@ plus a pointer:
|
||||
# Full rationale → qwen-autofix.md#af-030
|
||||
```
|
||||
|
||||
Each section below is the **verbatim** text of the block that pointer replaced,
|
||||
titled with the job and step it belongs to. Editing rules: keep the pointer and
|
||||
the section id in sync, put new long-form reasoning here rather than in the
|
||||
YAML, and never delete a section without deleting its pointer.
|
||||
Each section below carries the text of the block that pointer replaced, titled
|
||||
with the job and step it belongs to. A summary line may be rewritten after the
|
||||
block was compressed (the section is then authoritative, not the comment), and
|
||||
af-160 marks the entries whose text is new rather than moved. Editing rules:
|
||||
keep the pointer and the section id in sync, put new long-form reasoning here
|
||||
rather than in the YAML, and never delete a section without deleting its
|
||||
pointer.
|
||||
|
||||
This file records _why the code is the way it is_, indexed by code site. For
|
||||
task-oriented guides — what a maintainer types and what happens next — see:
|
||||
|
||||
- [`qwen-autofix-round-seed.md`](./qwen-autofix-round-seed.md) — seeding the
|
||||
round counter with `@qwen-code /takeover from N`.
|
||||
- [`qwen-autofix-round-seed.md`](./qwen-autofix-round-seed.md) — the two
|
||||
parameterized commands: seeding the round counter with `@qwen-code /takeover
|
||||
from N`, and bounding an engagement with `@qwen-code /takeover for N`.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -110,7 +114,7 @@ task-oriented guides — what a maintainer types and what happens next — see:
|
||||
- [4. route — The issue_comment clause is a cheap expression-level prefilter: the overwhelming…](#af-004)
|
||||
- [5. route — Concurrency is keyed by TARGET, not shared and not fully unique:](#af-005)
|
||||
- [6. route · Decide phases — Fork PR — decline, and say so. This event carries NO repository secrets: GitHub…](#af-006)
|
||||
- [7. route · Decide phases — '<cmd> from N' — the ONE parameterized form, and the only place this workflow reads a…](#af-007)
|
||||
- [7. route · Decide phases — '<cmd> from N' and '<cmd> for N' — the two parameterized forms, and the only places this…](#af-007)
|
||||
- [8. issue-autofix — Secret-bearing and executes agent-driven code, but the agent runs inside the docker…](#af-008)
|
||||
- [9. issue-autofix — route.issue_number is only set for forced dispatches; label events carry the issue in…](#af-009)
|
||||
- [10. issue-autofix · Sanitize workspace git config — The runner USER's global config is the same exec surface as the workspace config below:…](#af-010)
|
||||
@@ -259,6 +263,8 @@ task-oriented guides — what a maintainer types and what happens next — see:
|
||||
- [156. review-address · Triage and address — In-round self-review A/B: one bounded delta review before the round's commit,…](#af-156)
|
||||
- [157. review-address · Prepare branch and feedback — Classify the live head: a round that pushes onto a fully GREEN head and leaves…](#af-157)
|
||||
- [158. review-address · Stage trusted test-surface instrument — The test-weakening gate's instrument: the counter from the same trusted…](#af-158)
|
||||
- [159. route · Takeover round budget — '<cmd> for N' bounds a window's rounds and releases the PR when the budget is spent…](#af-159)
|
||||
- [160. (top level) · Env constant commentary — Moved out of the workflow verbatim to keep the file under GitHub's start-runs gate…](#af-160)
|
||||
|
||||
---
|
||||
|
||||
@@ -487,13 +493,14 @@ which already declines forks for exactly this reason.
|
||||
|
||||
<a id="af-007"></a>
|
||||
|
||||
### 7. route · Decide phases — '<cmd> from N' — the ONE parameterized form, and the only place this workflow reads a…
|
||||
### 7. route · Decide phases — '<cmd> from N' and '<cmd> for N' — the two parameterized forms, and the only places this…
|
||||
|
||||
In `route` · `Decide phases`.
|
||||
|
||||
```text
|
||||
'<cmd> from N' — the ONE parameterized form, and the only
|
||||
place this workflow reads a value out of a comment body.
|
||||
'<cmd> from N' and '<cmd> for N' — the two parameterized
|
||||
forms, and the only places this workflow reads a value out
|
||||
of a comment body.
|
||||
Kept inside the constants discipline above: the literal
|
||||
prefix must still match TAKEOVER_COMMAND byte-for-byte, the
|
||||
tail is a bounded integer, and the captured value only ever
|
||||
@@ -1361,9 +1368,12 @@ author-permission gate by design, not a head-repository gate: takeover
|
||||
engages maintainer fork PRs, and the pattern matches qwen-code-pr-review,
|
||||
whose ECS-routed review job also rides its upstream write+ check. The
|
||||
job therefore runs host-side (no `container:`): the branch code it
|
||||
executes is collaborator-authored — the same trust class ci.yml's
|
||||
pick_runner routes onto this pool — and persistent-workspace residue is
|
||||
scrubbed by the hygiene steps below. On pull_request /
|
||||
executes is collaborator-authored — a narrower trust class than ci.yml's
|
||||
pick_runner now routes onto this pool, since the 2026-10-31 decision
|
||||
sends every pull request, untrusted forks included, to lint_and_static
|
||||
(that lane pins a per-job HOME; this job does not) — and
|
||||
persistent-workspace residue is scrubbed by the hygiene steps below. On
|
||||
pull_request /
|
||||
pull_request_review events the ECS route additionally needs a same-repo
|
||||
head or a write+ author (ci.yml's pick_runner form); issue_comment and
|
||||
the other triggers skip that clause and rely on the live write+ gates.
|
||||
@@ -1399,8 +1409,8 @@ Why 20: 37 PRs carried the label on 2026-08-08, so 5 slots served
|
||||
at a larger scale. The ecs-qwen fleet is 84 runners, so 20 concurrent
|
||||
legs occupy under a quarter of it, and the executed legs sampled that
|
||||
day finished in 3-28 minutes.
|
||||
The 300-minute job cap puts the worst case at 5 runner-hours per slot
|
||||
(100 across the fleet at 20) and holds the per-PR head-write
|
||||
The 345-minute job cap puts the worst case at 5.75 runner-hours per
|
||||
slot (115 across the fleet at 20) and holds the per-PR head-write
|
||||
concurrency group for the same window. Different PRs never share that
|
||||
group, so raising this does not add push contention.
|
||||
RAISE BOTH TOGETHER: this must stay strictly below
|
||||
@@ -1640,11 +1650,11 @@ for this cap. Worst-case budget:
|
||||
setup 7
|
||||
Triage and address 130 (120 budget + 10 margin)
|
||||
Verification gate 60 (2.6x the measured 22m48s)
|
||||
Repair 20
|
||||
Repair 70 (60 budget + the same 10 margin)
|
||||
Repair verification 60
|
||||
report 3
|
||||
-------------------------------
|
||||
worst case 280 => job timeout 300, and the job runs
|
||||
worst case 330 => job timeout 345, and the job runs
|
||||
on ubuntu-latest, whose own ceiling
|
||||
is 360.
|
||||
```
|
||||
@@ -4400,6 +4410,10 @@ failed or incomplete pass as review-failed and pushes regardless.
|
||||
|
||||
In `review-address` · `Prepare branch and feedback`.
|
||||
|
||||
The lane names in `LOOP_OWNED_LANES` are pinned against the workflows' own
|
||||
`name:` lines by a test, so adding a lane means adding the workflow — not just
|
||||
the JSON entry.
|
||||
|
||||
```text
|
||||
Classify the live head: a round that pushes onto a fully
|
||||
GREEN head and leaves it red introduced the red. Until this
|
||||
@@ -4567,3 +4581,250 @@ presence branch, so a tree that has the counter but no
|
||||
installed typescript fails the step loudly instead of
|
||||
producing a round that quietly measures nothing.
|
||||
```
|
||||
|
||||
<a id="af-159"></a>
|
||||
|
||||
### 159. route · Takeover round budget — '<cmd> for N' bounds a window's rounds and releases the PR when the budget is spent…
|
||||
|
||||
In `route` · `Decide phases`, `takeover-command` · `Toggle takeover label`,
|
||||
`review-scan` · `Scan for PRs with new feedback`, `review-address` · `Prepare
|
||||
branch and feedback`, and the fleet shepherd's resume detector
|
||||
(`compute_resume_ts`), which has to recognise the same two command forms.
|
||||
|
||||
```text
|
||||
'@qwen-code /takeover' engages a persistent engagement capped at
|
||||
TAKEOVER_MAX_ROUNDS. Two workarounds existed for "handle this once":
|
||||
'from 99' (spends the cap in one change-producing round, but that round
|
||||
is Critical-only and the stop applies needs-human plus a "reached its
|
||||
round cap" notice, which reads as a failure — and a no-op round leaves
|
||||
the counter untouched, so it does not even bound a PR that keeps finding
|
||||
nothing) and a manual 'stop' (needs a human watching the run). Neither
|
||||
states "N rounds".
|
||||
|
||||
'for N' is the second parameterized form, and it is a separate marker
|
||||
rather than a field on the ack literal for the same reason the seed is
|
||||
(af-016): the ack is matched by substring everywhere. The re-arm invariants
|
||||
this relies on are recorded in docs/design/autofix-growth-audit.md — read
|
||||
that before changing how the audit re-arms.
|
||||
|
||||
The budget is enforced in two steps, and the second is why spend is
|
||||
counted in eval markers rather than in ROUND:
|
||||
|
||||
- The scan lowers EFF_MAX_ROUNDS to N for the ENGAGEMENT that carries
|
||||
the marker, so the existing cap gate bounds DISPATCH exactly as it
|
||||
always did. The effective cap's other reader is prepare's attempt
|
||||
recount.
|
||||
- Spend is the number of eval markers in the engagement, not ROUND. A
|
||||
no-op round writes its marker with the round UNCHANGED — that is why
|
||||
a no-op never advanced the cap — but it is still an attempt, and a
|
||||
budget counted in ROUND would never end for a PR whose rounds keep
|
||||
finding nothing. The census keys on `run=` (the run id, the dedup
|
||||
identity the growth marker already carries), falling back to
|
||||
`ts|round` for markers written before it existed: two executions
|
||||
that report the same watermark and round — the conflict no-op, which
|
||||
re-dispatches every tick — are two attempts, while a re-run of one
|
||||
run still charges once. A run that is cancelled or dies in setup is
|
||||
NOT free: the report step runs on always() and writes its marker with
|
||||
a sentinel timestamp, so the census charges it.
|
||||
- A breaker stop is NOT a spent budget. Three shapes reach the cap gate
|
||||
with the budget spent anyway: the consecutive-failure breaker jumps
|
||||
the round to MAX_ROUNDS with a real timestamp (no trailing sentinel),
|
||||
the cumulative timeout breaker counts timeouts across the window with
|
||||
pushes between them (so the trailing run stays short), and the
|
||||
convergence breaker only pushes. The release is gated on
|
||||
BUDGET_HALTED — TIMEOUT_WINDOW_CAP (3) sentinel-stamped rounds in the
|
||||
engagement with idle (silent-sandbox) rounds subtracted (af-073), a
|
||||
round past ROUND_START + attempts, or a convergence-break notice newer
|
||||
than the window key — so all three park on the paused path instead of
|
||||
releasing. ONE crash or timeout does not halt anything: it only spends
|
||||
its attempt.
|
||||
- A SPENT budget outlives the label's AUTOMATIC removal: the shepherd
|
||||
auto-releases a paused bot PR after three days, and if the budget died
|
||||
with that label the effective cap would fall back to 10 and rounds
|
||||
N+1..10 would run on a PR the dashboard still shows as awaiting a
|
||||
human. A spent budget therefore survives for a bot-authored
|
||||
engagement. An UNSPENT one still dies with the label, and a human
|
||||
release keeps no cap at all (af-160). An explicit 'stop' DOES end a
|
||||
spent budget: its 'takeover-ack released' marker is a floor boundary,
|
||||
so the engagement closes and standard management resumes — the one
|
||||
release the loop can tell apart from a silent label removal.
|
||||
- It is ENGAGEMENT-scoped, and that is not the same key as the window.
|
||||
The budget is read from every ack/re-arm at or after the anchor whose
|
||||
comment carries it — BUDGET_FLOOR: a 'takeover-ack engaged', a
|
||||
'takeover-ack released' (which CLOSES the engagement), or an
|
||||
'autofix-rearm' that does NOT also carry an 'autofix-eval ' marker.
|
||||
Round accounting (af-105) stays window-keyed instead: equality with
|
||||
the ack comment's created_at. So /retry or a bare /takeover opens an
|
||||
unbudgeted engagement (af-104), and re-arming with a budget starts a
|
||||
fresh one at round 0 — but an automatic growth-audit re-arm, whose
|
||||
comment carries the round's eval marker, does not. Collapsing this
|
||||
read into the window-key equality would drop the budget and the spend
|
||||
census on every automatic re-arm.
|
||||
|
||||
A budgeted window supersedes queued work INCLUDING a conflict round.
|
||||
Conflicts are normally exempt from the superseded-window check because
|
||||
they stay actionable, but a queued job runs on the OLD cap and would
|
||||
cross the new engagement limit, so the live window's budget makes it
|
||||
stale too and the next scan re-selects the conflict under the new
|
||||
window.
|
||||
|
||||
Two automatic paths need the same care. A growth audit appends
|
||||
`<!-- autofix-rearm -->` to rebase the round window for its own account-
|
||||
ing, and suppressing it would pin the growth baseline so every later
|
||||
round went Critical-only — so it stays unconditional. It is NOT the only
|
||||
thing that re-anchors that baseline: a base-branch update (which filters
|
||||
the earlier growth-base markers out of the baseline read, af-119/af-120)
|
||||
and the scan's first-pickup heal ack move it too. What keeps a budget
|
||||
honest is not the re-arm's exclusivity but its key — budget and spend are
|
||||
read per ENGAGEMENT — so no rebase can unbound a bounded engagement. And
|
||||
a queued round can outlive the sibling that spent the last attempt: a
|
||||
no-op leaves ROUND UNCHANGED and the window key unchanged, so neither
|
||||
the superseded-window check nor the round cap sees it — prepare and the
|
||||
scan both recount the engagement's eval markers.
|
||||
|
||||
What happens at the boundary differs by authorship, and that difference
|
||||
is the point. A HUMAN-authored PR is simply released: nothing is stuck,
|
||||
so the escalation label and the paused notice would both be false — the
|
||||
branch posts one bilingual note (dedup'd per window by its own marker)
|
||||
and DELETEs the label. A BOT-authored PR is managed without the label,
|
||||
so releasing it would hand the PR back to standard ten-round management
|
||||
and the budget would be a lie; it keeps the existing paused path
|
||||
(needs-human, shepherd auto-release) and is stopped at N for as long as
|
||||
the engagement is live. A spent budget outlives every removal that leaves
|
||||
no 'takeover-ack released' marker — the shepherd's three-day auto-release
|
||||
and a hand-removal alike — so the PR stays parked at N/N until a new
|
||||
command moves the engagement. Only an explicit 'stop' carries that
|
||||
marker, and that is what the ack promises.
|
||||
|
||||
The release re-reads live labels and fails closed on an unreadable
|
||||
snapshot, honors autofix/skip, and verifies the PAT identity before any
|
||||
write — the same consent and identity doctrine as the cap notice
|
||||
(af-106). The note posts BEFORE the DELETE, and a failed note SKIPS the
|
||||
DELETE: once the label is gone the PR is in neither selection pool, so
|
||||
nothing would ever re-post it, and "retry next scan" would be a promise
|
||||
no branch can keep.
|
||||
|
||||
A FORCED dispatch defers that release to the next non-forced scan. The
|
||||
shepherd's conflict dispatch has to be answered out loud (af-031), so the
|
||||
pass that meets a spent budget takes the paused path instead — cap-refused
|
||||
note, paused notice, needs-human — and the next passive scan releases. Two
|
||||
consequences are load-bearing. The release DELETEs needs-human as well
|
||||
(404-tolerant, and only after the takeover DELETE landed): the note says
|
||||
nothing is waiting on a human, so the escalation its own deferral applied
|
||||
must not outlive it. And the notices on that deferred pass quote the
|
||||
ATTEMPTS, not ROUND: a no-op-only engagement reaches this gate at round 0,
|
||||
and '0/N' would name a cap the PR never reached — the same reason the log
|
||||
line and the fleet row already speak of the budget rather than the cap.
|
||||
|
||||
'for 0' is rejected rather than aliased to the bare command: a
|
||||
zero-round engagement is what 'stop' already means (af-007).
|
||||
```
|
||||
|
||||
<a id="af-160"></a>
|
||||
|
||||
### 160. (top level) — Env constant commentary, moved out of the workflow…
|
||||
|
||||
In the workflow `env:` block.
|
||||
|
||||
The workflow file sits against this repository's own 470 KB gate
|
||||
(`.github/scripts/check-workflow-size.sh`, env-overridable, ~42 KB below
|
||||
GitHub's 512 KB start-runs limit), so the long-form commentary on the constants
|
||||
below lives here verbatim — with TWO exceptions, each marked at its entry: the
|
||||
`TAKEOVER_COMMAND` entry was updated to name the second parameterized form, and
|
||||
the `TRUSTED_ASSOC` entry gained the note on where the constant is read and what
|
||||
widening it would do. Neither is a byte-for-byte copy of the base block. Each
|
||||
entry names the constant it documents; the workflow keeps a one-line summary
|
||||
plus a pointer.
|
||||
|
||||
```text
|
||||
# FIXED_RULING_FILTER — The review bot's thread lifecycle replies
|
||||
# The review bot's thread lifecycle replies `R<id> fixed by <what>` into a
|
||||
# thread — a NOTE, never a finding: it carries its own marker
|
||||
# (FIXED_RULING_MARKER in lib/review-footer.ts) at the end of its first
|
||||
# line, and counting it selected a just-approved PR for a round with
|
||||
# nothing to address (#9940 review, round 28). Matched by its posted SHAPE,
|
||||
# anchored at the start, so a real Critical that QUOTES the marker (a
|
||||
# review of the file defining it) leads with its severity marker and keeps
|
||||
# counting — and so does one whose first line QUOTES a whole ruling and
|
||||
# then carries on, which the start anchor alone let through under
|
||||
# attribution off, where no severity marker leads the post (#9940
|
||||
# review, round 30). The anchor spans the WHOLE body: a comment that
|
||||
# quotes the note in full and then states its own finding underneath was
|
||||
# dropped from the census wholesale, the live Critical with it — a real
|
||||
# note is the note and, under attribution on, its footer, and anything
|
||||
# carrying further prose fails closed toward COUNTING (#9940 review,
|
||||
# round 31). `lib/review-footer.ts` exports the same shape as
|
||||
# FIXED_RULING_SHAPE_RE for the TypeScript readers. Defined ONCE and handed to every jq site as `--arg frf`: an
|
||||
# `--arg` value is the regex verbatim, so the class is spelled `[^\n]` with
|
||||
# ONE backslash here — the `[^\\n]` a jq string literal needs compiled, as a
|
||||
# shell value, to "neither backslash nor n" and let a `by` clause with an
|
||||
# `n` in it count (#9940 review, round 29).
|
||||
|
||||
# NON_BLOCKING_CHECKS — Checks the "wait for checks to settle" gate does NOT wait for.
|
||||
# Checks the "wait for checks to settle" gate does NOT wait for. The gate
|
||||
# exists so a FAILED check can be read as feedback, which is why build/test/
|
||||
# lint are still waited for. `review-pr` is the LLM code review: its output
|
||||
# is a REVIEW, delivered by its own real-time pull_request_review trigger and
|
||||
# counted by the review path — the check conclusion carries nothing the loop
|
||||
# acts on. Blocking on it bought nothing and cost a median 49 minutes per
|
||||
# round (p90 123, max 158, over 32 completed runs), during which the PR was
|
||||
# invisible to the scan even when it already had unaddressed feedback.
|
||||
# Names must match job ids in qwen-code-pr-review.yml; a test pins that.
|
||||
|
||||
# MAX_ROUNDS — Hard cap on automated review-address rounds per PR.
|
||||
# Hard cap on automated review-address rounds per PR. After this the bot stops
|
||||
# and leaves the PR for a human. Raised from 5: across the last 40 bot PRs
|
||||
# only 3 ever reached the cap and all 3 merged AT it (one having spent two of
|
||||
# its five rounds on the verify-gate ENOENT that #7330 fixed), so the ceiling
|
||||
# was near enough to bind on a bad day without any headroom for one. The cap
|
||||
# exists to stop an unproductive LOOP, not to ration ordinary iteration —
|
||||
# a genuinely stuck PR still stops, just later.
|
||||
|
||||
# TAKEOVER_COMMAND — Comment-command sugar over TAKEOVER_LABEL
|
||||
# Comment-command sugar over TAKEOVER_LABEL ('<cmd>' applies it, '<cmd>
|
||||
# stop' removes it). Matched EXACTLY against the trimmed comment body —
|
||||
# with TWO parameterized forms: '<cmd> from N' seeds this window's round
|
||||
# counter at N (af-002), and '<cmd> for N' records a round budget which the
|
||||
# scan reads back as a ceiling (af-159). The literal prefix still has to
|
||||
# match this constant byte-for-byte; only a bounded 1-2 digit integer is
|
||||
# read out of the body, and it reaches nothing but an integer comparison.
|
||||
|
||||
# TAKEOVER_MAX_ROUNDS — Round cap while TAKEOVER_LABEL is present.
|
||||
# Round cap while TAKEOVER_LABEL is present. Large managed PRs routinely
|
||||
# need dozens of feedback rounds — that is the point of takeover — so the
|
||||
# unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays
|
||||
# (a bot↔review-bot ping-pong is still bounded), it is just sized for
|
||||
# explicitly delegated work; removing the label restores the strict cap —
|
||||
# EXCEPT while a 'for N' budget is still live (af-159: a spent one on a
|
||||
# bot-authored PR outlives a silent removal and keeps clamping) — and
|
||||
# re-engaging opens a fresh counting window (see REARM_KEY below).
|
||||
|
||||
# RETRY_COMMAND — Re-arm sugar. Recovering a stranded PR
|
||||
# Re-arm sugar. Recovering a stranded PR previously meant DELETING the
|
||||
# bot's autofix-eval marker comment by hand (undiscoverable, destructive,
|
||||
# and it erases the audit trail). This command instead posts an
|
||||
# 'autofix-rearm' marker that supersedes the earlier evaluation markers:
|
||||
# the scan re-reads the feedback from scratch and the round counter resets.
|
||||
|
||||
# NEEDS_HUMAN_LABEL — Escalation label: applied when the loop STOPS
|
||||
# Escalation label: applied when the loop STOPS on a PR (round cap,
|
||||
# consecutive-failure or time-budget breaker) and a human must act —
|
||||
# re-arm, split, merge, or close. It makes paused PRs filterable and lets
|
||||
# the fleet shepherd age out unanswered stops. Removed wherever management
|
||||
# resumes (engage/re-arm) or a human releases the PR.
|
||||
|
||||
# TAKEOVER_LABEL / SKIP_LABEL — Maintainer-facing engagement labels
|
||||
# Maintainer-facing engagement labels (applying labels requires GitHub
|
||||
# triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR —
|
||||
# including a human-authored one — into the loop; SKIP opts any PR out
|
||||
# everywhere, and wins when both are present.
|
||||
|
||||
# TRUSTED_ASSOC — Human feedback: maintainers only (prompt-injection gate)
|
||||
# Human reviews/comments only count when the author is a real maintainer. This
|
||||
# is the prompt-injection trust gate: feedback from anyone else is ignored so a
|
||||
# hostile commenter cannot steer the agent.
|
||||
# NOT moved verbatim: the two sentences below are new in this change (af-160).
|
||||
# It is matched against `author_association` at every census site; widening it
|
||||
# (NONE, FIRST_TIME_CONTRIBUTOR) hands untrusted text to an agent that runs
|
||||
# with a write PAT.
|
||||
```
|
||||
+188
-223
@@ -71,161 +71,62 @@ permissions:
|
||||
contents: 'read'
|
||||
|
||||
env:
|
||||
# Identity of the autofix bot. All open in-repo PRs authored by this bot are
|
||||
# eligible for the review phase — not limited to autofix/issue-* branches.
|
||||
# Autofix bot identity: ALL its open in-repo PRs are eligible, not just autofix/issue-*.
|
||||
AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}"
|
||||
# Branch-name prefix used by the issue phase when creating PRs. Also used
|
||||
# for duplicate-PR detection and issue-number extraction from branch names.
|
||||
# Branch prefix for issue-phase PRs; also drives duplicate detection and issue-number extraction.
|
||||
BRANCH_PREFIX: 'autofix/issue-'
|
||||
# The automated Qwen PR reviewer posts as this account; its review counts as
|
||||
# actionable feedback even though it is not a human collaborator.
|
||||
# The Qwen PR reviewer: its review counts as actionable feedback without collaborator status.
|
||||
REVIEW_BOT: 'qwen-code-ci-bot'
|
||||
# Human reviews/comments only count when the author is a real maintainer. This
|
||||
# is the prompt-injection trust gate: feedback from anyone else is ignored so a
|
||||
# hostile commenter cannot steer the agent.
|
||||
# Human feedback: maintainers only (prompt-injection gate). → qwen-autofix.md#af-160
|
||||
TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]'
|
||||
# Every lane this loop owns whose check runs attach to the PR head:
|
||||
# excluded from the head-state classifiers (charge + wake) by name, or
|
||||
# the loop reads its own red as the round's regression (af-157, R27-23).
|
||||
# A test pins the names against the workflows' `name:` lines.
|
||||
# Loop-owned lanes excluded from the head-state classifiers. → qwen-autofix.md#af-157
|
||||
LOOP_OWNED_LANES: '["Qwen Autofix","🧐 Qwen Pull Request Review","Qwen CI Failure Patrol","Qwen Autofix Fork Bridge","Qwen Autofix Fork Signal","Qwen Triage","Qwen Triage Finalize","PR self-report label","Qwen PR Safety Precheck","Comment Attachment Guard","PR Asset Branch Cleanup"]'
|
||||
# The review bot's thread lifecycle replies `R<id> fixed by <what>` into a
|
||||
# thread — a NOTE, never a finding: it carries its own marker
|
||||
# (FIXED_RULING_MARKER in lib/review-footer.ts) at the end of its first
|
||||
# line, and counting it selected a just-approved PR for a round with
|
||||
# nothing to address (#9940 review, round 28). Matched by its posted SHAPE,
|
||||
# anchored at the start, so a real Critical that QUOTES the marker (a
|
||||
# review of the file defining it) leads with its severity marker and keeps
|
||||
# counting — and so does one whose first line QUOTES a whole ruling and
|
||||
# then carries on, which the start anchor alone let through under
|
||||
# attribution off, where no severity marker leads the post (#9940
|
||||
# review, round 30). The anchor spans the WHOLE body: a comment that
|
||||
# quotes the note in full and then states its own finding underneath was
|
||||
# dropped from the census wholesale, the live Critical with it — a real
|
||||
# note is the note and, under attribution on, its footer, and anything
|
||||
# carrying further prose fails closed toward COUNTING (#9940 review,
|
||||
# round 31). `lib/review-footer.ts` exports the same shape as
|
||||
# FIXED_RULING_SHAPE_RE for the TypeScript readers. Defined ONCE and handed to every jq site as `--arg frf`: an
|
||||
# `--arg` value is the regex verbatim, so the class is spelled `[^\n]` with
|
||||
# ONE backslash here — the `[^\\n]` a jq string literal needs compiled, as a
|
||||
# shell value, to "neither backslash nor n" and let a `by` clause with an
|
||||
# `n` in it count (#9940 review, round 29).
|
||||
# Shape of the review bot's 'R<id> fixed by' note, which never counts as feedback (#9940). → qwen-autofix.md#af-160
|
||||
FIXED_RULING_FILTER: '^R[0-9]+-[0-9]+ fixed(?: by [^\r\n\x{2028}\x{2029}]*)? <!-- qwen-review-fixed-ruling -->[ \t]*(\r?\n[ \t\r\n]*_— ((?! via Qwen Code /review)[^\r\n\x{2028}\x{2029}])* via Qwen Code /review( \(v[A-Za-z0-9._+-]{0,200}…?\)?)?_[ \t]*)?([\r\n][ \t\r\n]*)?$'
|
||||
# Hard cap on automated review-address rounds per PR. After this the bot stops
|
||||
# and leaves the PR for a human. Raised from 5: across the last 40 bot PRs
|
||||
# only 3 ever reached the cap and all 3 merged AT it (one having spent two of
|
||||
# its five rounds on the verify-gate ENOENT that #7330 fixed), so the ceiling
|
||||
# was near enough to bind on a bad day without any headroom for one. The cap
|
||||
# exists to stop an unproductive LOOP, not to ration ordinary iteration —
|
||||
# a genuinely stuck PR still stops, just later.
|
||||
# Hard cap on automated review-address rounds per PR; a stuck PR stops later, not never. → qwen-autofix.md#af-160
|
||||
MAX_ROUNDS: '10'
|
||||
# Suggestions may improve a PR, but continuing to implement them after five
|
||||
# change-producing rounds expands the diff and creates fresh review churn.
|
||||
# Full rationale → qwen-autofix.md#af-002
|
||||
# Suggestions stop after CRITICAL_ONLY_AFTER_ROUND change-producing rounds. → qwen-autofix.md#af-002
|
||||
CRITICAL_ONLY_AFTER_ROUND: '5'
|
||||
# Per-author tail budget inside Critical-only mode.
|
||||
# Full rationale → qwen-autofix.md#af-074
|
||||
CRITICAL_ONLY_HUMAN_BATCHES: '2'
|
||||
# Net-diff growth budgets per counting window — the SIZE sibling of the
|
||||
# round brake above. CRITICAL_ONLY_AFTER_ROUND counts rounds, but one round
|
||||
# Full rationale → qwen-autofix.md#af-003
|
||||
# Net-diff growth budgets per counting window — the SIZE sibling of the round brake. → qwen-autofix.md#af-003
|
||||
GROWTH_BUDGET_SRC_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_SRC_LINES || 400 }}'
|
||||
GROWTH_BUDGET_TEST_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_TEST_LINES || 400 }}'
|
||||
# Growth audit: a budget breach engages Critical-only AND makes the round a
|
||||
# growth-audit round.
|
||||
# Full rationale → qwen-autofix.md#af-075
|
||||
# A budget breach engages Critical-only and makes the round a growth-audit round. → qwen-autofix.md#af-075
|
||||
API_AUTH_MAX_ROUNDS: '3'
|
||||
# Checks the "wait for checks to settle" gate does NOT wait for. The gate
|
||||
# exists so a FAILED check can be read as feedback, which is why build/test/
|
||||
# lint are still waited for. `review-pr` is the LLM code review: its output
|
||||
# is a REVIEW, delivered by its own real-time pull_request_review trigger and
|
||||
# counted by the review path — the check conclusion carries nothing the loop
|
||||
# acts on. Blocking on it bought nothing and cost a median 49 minutes per
|
||||
# round (p90 123, max 158, over 32 completed runs), during which the PR was
|
||||
# invisible to the scan even when it already had unaddressed feedback.
|
||||
# Names must match job ids in qwen-code-pr-review.yml; a test pins that.
|
||||
# Checks the settle gate skips: 'review-pr' carries nothing the loop acts on. → qwen-autofix.md#af-160
|
||||
NON_BLOCKING_CHECKS: '["review-pr"]'
|
||||
# Failed-check annotation patterns that mean the INFRASTRUCTURE died, not the
|
||||
# code — a self-hosted runner losing the server, the disk filling, a runner
|
||||
# shutdown, a git fetch/clone dying mid-transfer, the hosted pool never
|
||||
# handing the job a runner (#13622: "repeatedly failed to be acquired"), or a
|
||||
# shared-host test lock starving past its per-acquisition budget (#14550:
|
||||
# "sdk-java host lock not acquired within 40 minutes").
|
||||
# Full rationale → qwen-autofix.md#af-076
|
||||
# Failed-check patterns that mean INFRASTRUCTURE died, not the code. → qwen-autofix.md#af-076
|
||||
INFRA_FAILURE_SIGNATURES: 'lost communication with the server|No space left on device|ENOSPC|received a shutdown signal|The runner has received|Failed to initialize container|runner (was|has been) (lost|terminated)|invalid index-pack output|RPC failed|failed to be acquired|lock not acquired within'
|
||||
# Upper bound on review targets emitted per scan (fan-out defense-in-depth;
|
||||
# excess is logged and deferred to the next scan).
|
||||
# Full rationale → qwen-autofix.md#af-077
|
||||
# Max review targets per scan (fan-out defense). → qwen-autofix.md#af-077
|
||||
MAX_TARGETS_PER_SCAN: '${{ vars.QWEN_AUTOFIX_MAX_TARGETS_PER_SCAN || 30 }}'
|
||||
# Upper bound on candidates INSPECTED per scan: idle candidates consume
|
||||
# serial API calls even when they emit nothing, and takeover widens the
|
||||
# candidate pool.
|
||||
# Upper bound on candidates INSPECTED per scan.
|
||||
# Full rationale → qwen-autofix.md#af-078
|
||||
MAX_CANDIDATE_INSPECTIONS: '${{ vars.QWEN_AUTOFIX_MAX_CANDIDATE_INSPECTIONS || 60 }}'
|
||||
# Maintainer-facing engagement labels (applying labels requires GitHub
|
||||
# triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR —
|
||||
# including a human-authored one — into the loop; SKIP opts any PR out
|
||||
# everywhere, and wins when both are present.
|
||||
# TAKEOVER opts in; SKIP opts out and wins. → qwen-autofix.md#af-160
|
||||
TAKEOVER_LABEL: 'autofix/takeover'
|
||||
SKIP_LABEL: 'autofix/skip'
|
||||
# Commit-status context stamped PENDING on a PR head when a scan dispatches
|
||||
# a review-address leg for it, and re-stamped SUCCESS by the leg on
|
||||
# checkout.
|
||||
# Full rationale → qwen-autofix.md#af-079
|
||||
# PENDING marker stamped on a PR head when a scan dispatches. → qwen-autofix.md#af-079
|
||||
DISPATCH_STATUS_CONTEXT: 'qwen-autofix/dispatch-pending'
|
||||
DISPATCH_STATUS_TTL_MINUTES: '30'
|
||||
# Comment-command sugar over TAKEOVER_LABEL ('<cmd>' applies it, '<cmd>
|
||||
# stop' removes it). Matched EXACTLY against the trimmed comment body —
|
||||
# with ONE parameterized form, '<cmd> from N', which applies the label and
|
||||
# additionally seeds this window's round counter at N (see
|
||||
# CRITICAL_ONLY_AFTER_ROUND). The literal prefix still has to match this
|
||||
# constant byte-for-byte; only a bounded 1-2 digit integer is read out of
|
||||
# the body, and it reaches nothing but an integer comparison.
|
||||
# Comment-command sugar: '<cmd>', 'stop', 'from N', 'for N' — exact match only. → qwen-autofix-round-seed.md
|
||||
TAKEOVER_COMMAND: '@qwen-code /takeover'
|
||||
# Escalation label: applied when the loop STOPS on a PR (round cap,
|
||||
# consecutive-failure or time-budget breaker) and a human must act —
|
||||
# re-arm, split, merge, or close. It makes paused PRs filterable and lets
|
||||
# the fleet shepherd age out unanswered stops. Removed wherever management
|
||||
# resumes (engage/re-arm) or a human releases the PR.
|
||||
# Escalation label applied when the loop STOPS and a human must act. → qwen-autofix.md#af-160
|
||||
NEEDS_HUMAN_LABEL: 'autofix/needs-human'
|
||||
# Re-arm sugar. Recovering a stranded PR previously meant DELETING the
|
||||
# bot's autofix-eval marker comment by hand (undiscoverable, destructive,
|
||||
# and it erases the audit trail). This command instead posts an
|
||||
# 'autofix-rearm' marker that supersedes the earlier evaluation markers:
|
||||
# the scan re-reads the feedback from scratch and the round counter resets.
|
||||
# Re-arm sugar: an 'autofix-rearm' marker supersedes earlier eval markers. → qwen-autofix.md#af-160
|
||||
RETRY_COMMAND: '@qwen-code /retry'
|
||||
# Round cap while TAKEOVER_LABEL is present. Large managed PRs routinely
|
||||
# need dozens of feedback rounds — that is the point of takeover — so the
|
||||
# unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays
|
||||
# (a bot↔review-bot ping-pong is still bounded), it is just sized for
|
||||
# explicitly delegated work; removing the label restores the strict cap,
|
||||
# and re-engaging opens a fresh counting window (see REARM_KEY below).
|
||||
# Round cap while TAKEOVER_LABEL is present; 'for N' may lower it. → qwen-autofix.md#af-160
|
||||
TAKEOVER_MAX_ROUNDS: '100'
|
||||
# Consecutive-failure sub-cap, distinct from the total round cap above. The
|
||||
# total cap bounds how many PRODUCTIVE rounds a PR may take; this bounds how
|
||||
# many rounds may fail IN A ROW with nothing pushed.
|
||||
# Full rationale → qwen-autofix.md#af-080
|
||||
# Consecutive-failure sub-cap: rounds that may fail in a row with nothing pushed. → qwen-autofix.md#af-080
|
||||
CONSECUTIVE_FAILURE_CAP: '5'
|
||||
# Cumulative agent-timeout sub-cap, the sibling of the consecutive cap for
|
||||
# the failure shape it cannot see: timeouts INTERLEAVED with successful
|
||||
# rounds.
|
||||
# Full rationale → qwen-autofix.md#af-081
|
||||
# Cumulative agent-timeout sub-cap. → qwen-autofix.md#af-081
|
||||
TIMEOUT_WINDOW_CAP: '3'
|
||||
# Convergence-signal circuit breaker — the off-ramp the round and growth
|
||||
# brakes cannot provide (#10107): N consecutive review rounds carrying a
|
||||
# machine-readable non-convergence code, with no trusted-human response
|
||||
# in between, pause the loop. Tunable without a code change; a malformed
|
||||
# value falls back to its default at the read site.
|
||||
# Full rationale → qwen-autofix.md#af-151
|
||||
# Consecutive non-convergence rounds before the loop pauses. → qwen-autofix.md#af-151
|
||||
CONVERGENCE_BREAK_ROUNDS: '${{ vars.QWEN_AUTOFIX_CONVERGENCE_BREAK_ROUNDS || 3 }}'
|
||||
# Which codes count as the signal (jq-ready JSON array). The vocabulary
|
||||
# is the review CLI's RECOMMENDATION_CODES, published per round as `rec`
|
||||
# in the posted ledger marker; a test pins this list inside it.
|
||||
# 'land-and-defer' is deliberately absent — see af-151.
|
||||
# Convergence signal codes. → qwen-autofix.md#af-151
|
||||
CONVERGENCE_SIGNAL_CODES: '["root-cause-triage","successor-chain","batch-fixes","stem-surface"]'
|
||||
# In-round self-review A/B: off (default) | ab (odd PRs armed) | on —
|
||||
# ONE bounded delta review before the round's commit, never a loop.
|
||||
# Full rationale → qwen-autofix.md#af-156
|
||||
# In-round self-review A/B: off (default) | ab (odd PRs armed) | on. → qwen-autofix.md#af-156
|
||||
SELF_REVIEW: "${{ vars.QWEN_AUTOFIX_SELF_REVIEW || 'off' }}"
|
||||
# Agent budget (ms) for an ARMED round; clamped in the address step.
|
||||
SELF_REVIEW_TIMEOUT_MS: '${{ vars.QWEN_AUTOFIX_SELF_REVIEW_TIMEOUT_MS || 10800000 }}'
|
||||
@@ -268,6 +169,7 @@ jobs:
|
||||
ack_base: '${{ steps.decide.outputs.ack_base }}'
|
||||
takeover_cmd: '${{ steps.decide.outputs.takeover_cmd }}'
|
||||
takeover_from: '${{ steps.decide.outputs.takeover_from }}'
|
||||
takeover_for: '${{ steps.decide.outputs.takeover_for }}'
|
||||
retry_pr: '${{ steps.decide.outputs.retry_pr }}'
|
||||
cmd_pr: '${{ steps.decide.outputs.cmd_pr }}'
|
||||
review_sender: '${{ github.event.review.user.login }}'
|
||||
@@ -328,6 +230,8 @@ jobs:
|
||||
# parser below). Empty on every other path, which reads as "start
|
||||
# this window at round 0" — the pre-existing behaviour.
|
||||
TAKEOVER_FROM=''
|
||||
# Engagement round budget from '<cmd> for N'; empty = takeover cap.
|
||||
TAKEOVER_FOR=''
|
||||
CMD_PR=''
|
||||
RETRY_PR=''
|
||||
DRY_RUN="${DRY_RUN_INPUT:-false}"
|
||||
@@ -420,8 +324,8 @@ jobs:
|
||||
CMD=''
|
||||
[[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND}" ]] && CMD='add'
|
||||
[[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND} stop" ]] && CMD='remove'
|
||||
# '<cmd> from N' — the ONE parameterized form, and the only
|
||||
# place this workflow reads a value out of a comment body.
|
||||
# '<cmd> from N' / 'for N' — the two parameterized forms,
|
||||
# the only places this reads a value out of a comment body.
|
||||
# Full rationale → qwen-autofix.md#af-007
|
||||
# Operator guide → qwen-autofix-round-seed.md
|
||||
if [[ "${BODY_TRIMMED}" =~ ^(.*)\ from\ ([0-9]{1,2})$ ]]; then
|
||||
@@ -440,6 +344,18 @@ jobs:
|
||||
TAKEOVER_FROM="$((10#${BASH_REMATCH[2]}))"
|
||||
fi
|
||||
fi
|
||||
# '<cmd> for N' — the round-budget form. Unlike the seed, 0 is
|
||||
# not a meaningful spelling (that is 'stop'), so it fails closed.
|
||||
# Full rationale → qwen-autofix.md#af-159
|
||||
if [[ "${BODY_TRIMMED}" =~ ^(.*)\ for\ ([0-9]{1,2})$ ]]; then
|
||||
if [[ "${BASH_REMATCH[1]}" == "${TAKEOVER_COMMAND}" ]]; then
|
||||
FOR_CAND="$((10#${BASH_REMATCH[2]}))"
|
||||
if [[ "${FOR_CAND}" -ge 1 ]]; then
|
||||
CMD='add'
|
||||
TAKEOVER_FOR="${FOR_CAND}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Re-arm shares the takeover command's authorization exactly:
|
||||
# both summon bot activity on a managed PR, so inventing a
|
||||
# second policy would only add surface.
|
||||
@@ -646,6 +562,7 @@ jobs:
|
||||
# the only shape the marker reader accepts is a bare 1-2 digit
|
||||
# integer. sanitize_number rejects (and warns about) anything else.
|
||||
echo "takeover_from=$(sanitize_number "${TAKEOVER_FROM}")" >> "${GITHUB_OUTPUT}"
|
||||
echo "takeover_for=$(sanitize_number "${TAKEOVER_FOR}")" >> "${GITHUB_OUTPUT}"
|
||||
echo "retry_pr=${RETRY_PR}" >> "${GITHUB_OUTPUT}"
|
||||
echo "cmd_pr=${CMD_PR}" >> "${GITHUB_OUTPUT}"
|
||||
echo "🧭 phase='${PHASE:-auto}' event='${EVENT_NAME}' issue='#${ISSUE_NUMBER:-n/a}' pr='#${PR_NUMBER_EVENT:-n/a}' schedule='${SCHEDULE:-n/a}' dry_run=${DRY_RUN} → issue=${DO_ISSUE} review=${DO_REVIEW}"
|
||||
@@ -1732,6 +1649,7 @@ jobs:
|
||||
REPO: '${{ github.repository }}'
|
||||
CMD: '${{ needs.route.outputs.takeover_cmd }}'
|
||||
CMD_FROM: '${{ needs.route.outputs.takeover_from }}'
|
||||
CMD_FOR: '${{ needs.route.outputs.takeover_for }}'
|
||||
PR: '${{ needs.route.outputs.cmd_pr }}'
|
||||
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
|
||||
steps:
|
||||
@@ -1846,12 +1764,22 @@ jobs:
|
||||
FROM_NOTE_REARM="$(printf ' This window'"'"'s round counter restarts at %s (rounds already spent on this PR), so the Critical-only brake engages after %s more change-producing round(s) instead of a full fresh %s.' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")"
|
||||
FROM_NOTE_REARM_ZH="$(printf '本窗口轮次计数从 %s 重启(即本 PR 已消耗的轮次),因此再经过 %s 个产生改动的轮次即进入 Critical-only,而非重新计满 %s 轮。' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")"
|
||||
fi
|
||||
# Round budget carried by '<cmd> for N': its own marker on the seed's
|
||||
# window anchor, and a CEILING the scan lowers EFF_MAX_ROUNDS to.
|
||||
FOR_MARKER=''
|
||||
FOR_NOTE=''
|
||||
FOR_NOTE_ZH=''
|
||||
if [[ -n "${CMD_FOR}" && "${CMD_FOR}" =~ ^[0-9]{1,2}$ && "${CMD_FOR}" != '0' ]]; then
|
||||
FOR_MARKER="$(printf '\n<!-- autofix-round-budget %s -->' "${CMD_FOR}")"
|
||||
FOR_NOTE="$(printf ' Budget: %s round(s). After round %s — pushed, no-op or failed — the loop removes the `%s` label (a bot-authored PR keeps it and pauses for a human instead).' "${CMD_FOR}" "${CMD_FOR}" "${TAKEOVER_LABEL}")"
|
||||
FOR_NOTE_ZH="$(printf '本次托管预算 %s 轮:第 %s 轮结束(推送、无改动或失败)后自动移除 `%s` 标签(bot 创建的 PR 保留标签并转为人工作业)。' "${CMD_FOR}" "${CMD_FOR}" "${TAKEOVER_LABEL}")"
|
||||
fi
|
||||
if [[ "${CMD}" == 'add' ]]; then
|
||||
if [[ "${HAS}" == 'true' ]]; then
|
||||
# Already managed: repeating the command is the ROUND-COUNTER
|
||||
# RESET. A fresh engage ack starts a new counting window (only
|
||||
# Full rationale → qwen-autofix.md#af-017
|
||||
REARM_BODY="$(printf '🔄 Takeover re-armed: the round counter starts a fresh window%s; management continues.%s\n\n<details>\n<summary>中文说明</summary>\n\n🔄 已重新武装:轮次计数开启新窗口%s,托管继续。%s\n\n</details>\n\n<!-- takeover-ack engaged -->%s' "${REARM_FRESH_CLAUSE}" "${FROM_NOTE_REARM}" "${REARM_FRESH_CLAUSE_ZH}" "${FROM_NOTE_REARM_ZH}" "${FROM_MARKER}")"
|
||||
REARM_BODY="$(printf '🔄 Takeover re-armed: the round counter starts a fresh window%s; management continues.%s%s\n\n<details>\n<summary>中文说明</summary>\n\n🔄 已重新武装:轮次计数开启新窗口%s,托管继续。%s%s\n\n</details>\n\n<!-- takeover-ack engaged -->%s%s' "${REARM_FRESH_CLAUSE}" "${FROM_NOTE_REARM}" "${FOR_NOTE}" "${REARM_FRESH_CLAUSE_ZH}" "${FROM_NOTE_REARM_ZH}" "${FOR_NOTE_ZH}" "${FROM_MARKER}" "${FOR_MARKER}")"
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}" \
|
||||
|| { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}"; } \
|
||||
|| { echo "::error::re-arm ack comment failed on #${PR} after one retry — the round window was NOT reset and no seed landed; re-run the command"; exit 1; }
|
||||
@@ -1887,7 +1815,7 @@ jobs:
|
||||
# only copy lives in this body, and the heal ack has no slot
|
||||
# to recover it — a transient 5xx must not silently un-seed
|
||||
# the window.
|
||||
ENGAGE_BODY="$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s%s Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->%s' "${FORK_NOTE}" "${FROM_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${FROM_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FROM_MARKER}")"
|
||||
ENGAGE_BODY="$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s%s%s Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s%s%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->%s%s' "${FORK_NOTE}" "${FROM_NOTE}" "${FOR_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${FROM_NOTE_ZH}" "${FOR_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FROM_MARKER}" "${FOR_MARKER}")"
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}" \
|
||||
|| { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}"; } \
|
||||
|| echo "::warning::engage ack comment failed on #${PR}; the scan's first-pickup ack heals it"
|
||||
@@ -2876,31 +2804,20 @@ jobs:
|
||||
# Eval markers the bot left after a previous evaluation carry the
|
||||
# newest feedback timestamp it already considered, plus the round.
|
||||
# Only our own comments are trusted, so a spoofed marker is ignored.
|
||||
# NOTE: this regex is POSITIONAL — group .[0]=ts, .[2]=round — and must
|
||||
# match the marker string emitted at every write site verbatim (search
|
||||
# `autofix-eval ts=`: the push/report success, noop, and handoff steps).
|
||||
# Inserting or reordering a field here or at any write site silently
|
||||
# corrupts round tracking; keep the `ts= acted= round=` order in lockstep.
|
||||
# POSITIONAL: .[0]=ts .[2]=round .[3]=win .[4]=run; every write
|
||||
# site must emit that order verbatim. → qwen-autofix.md#af-159
|
||||
MARKERS="$(jq -c --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[]
|
||||
| {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")"
|
||||
# The watermark is GLOBAL across all markers — feedback already
|
||||
# evaluated stays evaluated, no matter how counting windows move.
|
||||
# The terminal-handoff SENTINEL ts is excluded: it is a flag, not
|
||||
# an evaluation time, and letting it into the watermark would
|
||||
# filter all future feedback forever — making a re-arm after a
|
||||
# terminal handoff dead on arrival (terminal skipping itself is
|
||||
# round-based and window-scoped, so re-arm properly clears it).
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))?(?: run=([^ ]+))? -->") ] | .[]
|
||||
| {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), run: .[4], at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")"
|
||||
# GLOBAL watermark; the terminal-handoff sentinel ts is a flag,
|
||||
# not an evaluation time. → qwen-autofix.md#af-159
|
||||
REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-rearm -->"))
|
||||
| .created_at ] | max // ""' "${WORKDIR}/ic.json")"
|
||||
# The head this PR's red checks were last reported against. Carried
|
||||
# as its OWN marker inside the eval comment rather than a new field
|
||||
# on autofix-eval, so none of the ts/acted/round parsers change —
|
||||
# and the comment still matches the eval filter, so the agent never
|
||||
# sees it as feedback.
|
||||
# Last reported red head: its own marker, and it still matches the
|
||||
# eval filter so the agent never sees it. → qwen-autofix.md#af-159
|
||||
RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
|
||||
| [ scan("<!-- autofix-redcheck head=([0-9a-f]+) -->") ] | .[]
|
||||
@@ -2936,23 +2853,44 @@ jobs:
|
||||
| ((.body // "") | [ scan("<!-- autofix-round-start ([0-9]{1,2}) -->") ] | .[] | .[0]) ]
|
||||
| last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')"
|
||||
[[ "${ROUND_START}" =~ ^[0-9]{1,2}$ ]] || ROUND_START=0
|
||||
# Clamp strictly below the effective cap. The seed must be able to
|
||||
# bring the Critical-only brake forward; it must NEVER be able to
|
||||
# park a PR at its round cap on the very round it is taken over
|
||||
# (which would stop the loop instead of starting it), and a seed
|
||||
# is honoured on any PR whose window anchor carries the marker —
|
||||
# including one whose takeover label was later removed, dropping
|
||||
# EFF_MAX_ROUNDS back to the strict 10.
|
||||
# Per ENGAGEMENT, not per window. → qwen-autofix.md#af-159
|
||||
BUDGET_FLOOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") as $b | ($b | contains("<!-- takeover-ack engaged -->")) or ($b | contains("<!-- takeover-ack released -->")) or (($b | contains("<!-- autofix-rearm -->")) and (($b | contains("<!-- autofix-eval ")) | not))) | .created_at ] | max // ""' "${WORKDIR}/ic.json" 2> /dev/null || echo '')"
|
||||
ROUND_BUDGET="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg f "${BUDGET_FLOOR}" '[ .[] | select((.user.login // "") == $ab) | select((.created_at // "") >= $f) | ((.body // "") | [ scan("<!-- autofix-round-budget ([1-9][0-9]?) -->") ] | .[] | .[0]) ] | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')"
|
||||
[[ "${ROUND_BUDGET}" =~ ^[0-9]{1,2}$ ]] || ROUND_BUDGET=0
|
||||
IS_BOT_AUTHOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '((.author.login // "") == $ab)' <<< "${PR_META}")"
|
||||
# One ATTEMPT = one run= identity, else the legacy ts|round pair.
|
||||
BUDGET_USED="$(jq -r --arg f "${BUDGET_FLOOR}" '[ .[] | select(.win != "none" and .win >= $f) | (.run // "\(.ts)|\(.round)") ] | unique | length' <<< "${MARKERS}" 2> /dev/null || echo 0)"
|
||||
[[ "${BUDGET_USED}" =~ ^[0-9]+$ ]] || BUDGET_USED=0
|
||||
BUDGET_SPENT=false
|
||||
[[ "${ROUND_BUDGET}" -gt 0 && "${BUDGET_USED}" -ge "${ROUND_BUDGET}" ]] && BUDGET_SPENT=true
|
||||
# A SPENT budget outlives the label's automatic removal; unspent dies with it.
|
||||
[[ "${HAS_TAKEOVER:-false}" == "true" || ( "${IS_BOT_AUTHOR}" == 'true' && "${BUDGET_SPENT}" == 'true' ) ]] || { ROUND_BUDGET=0; BUDGET_SPENT=false; }
|
||||
if [[ "${ROUND_BUDGET}" -gt 0 && "${ROUND_BUDGET}" -lt "${EFF_MAX_ROUNDS}" ]]; then
|
||||
EFF_MAX_ROUNDS="${ROUND_BUDGET}"
|
||||
fi
|
||||
# A seed clamps strictly below the cap: it must never park the PR on
|
||||
# the round that takes it over. → qwen-autofix.md#af-105
|
||||
if [[ "${EFF_MAX_ROUNDS:-0}" -gt 0 && "${ROUND_START}" -ge "${EFF_MAX_ROUNDS}" ]]; then
|
||||
echo "🔢 #${PR}: round seed ${ROUND_START} clamped to $(( EFF_MAX_ROUNDS - 1 )) (effective cap ${EFF_MAX_ROUNDS})"
|
||||
ROUND_START=$(( EFF_MAX_ROUNDS - 1 ))
|
||||
fi
|
||||
ROUND="$(jq -r --arg key "${REARM_KEY}" --argjson start "${ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${MARKERS}")"
|
||||
# A breaker stop is NOT a spent budget: a sentinel in the engagement,
|
||||
# a round past the attempts, or a convergence notice parks the PR.
|
||||
# Idle (silent-sandbox) timeouts write the sentinel too, but the
|
||||
# cumulative breaker excludes them (af-073): map their runs and
|
||||
# drop them here as well.
|
||||
BUDGET_IDLE_RUNS="$(jq -c --arg ab "${AUTOFIX_BOT}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("(idle-timeout")) | (.body // "") | scan("actions/runs/([0-9]+)") | .[0] ] | unique' "${WORKDIR}/ic.json" 2> /dev/null || echo '[]')"
|
||||
BUDGET_SENTINELS="$(jq -r --arg f "${BUDGET_FLOOR}" --argjson idle "${BUDGET_IDLE_RUNS:-[]}" '[ .[] | select(.win != "none" and .win >= $f) | select(.ts == "9999-12-31T23:59:59Z") | select((.run // "") as $r | ($idle | index($r)) == null) ] | length' <<< "${MARKERS}" 2> /dev/null || echo 0)"
|
||||
[[ "${BUDGET_SENTINELS}" =~ ^[0-9]+$ ]] || BUDGET_SENTINELS=0
|
||||
BUDGET_HALTED=false
|
||||
[[ "${ROUND}" -gt "$(( ROUND_START + BUDGET_USED ))" ]] && BUDGET_HALTED=true
|
||||
[[ "${BUDGET_SENTINELS}" -ge "${TIMEOUT_WINDOW_CAP}" ]] && BUDGET_HALTED=true
|
||||
BUDGET_CONV="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${REARM_KEY}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("<!-- autofix-convergence-break -->")) | select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)"
|
||||
[[ "${BUDGET_CONV}" =~ ^[0-9]+$ ]] || BUDGET_CONV=0
|
||||
[[ "${BUDGET_CONV}" != '0' ]] && BUDGET_HALTED=true
|
||||
# No mention of the Critical-only threshold here, deliberately: the
|
||||
# scan must stay ignorant of that brake. It keeps SELECTING fresh
|
||||
# suggestions so a no-op report can still advance the watermark;
|
||||
# only prepare hides them from the agent. A test pins the scan
|
||||
# against the string.
|
||||
# scan stays ignorant of that brake and only prepare hides it.
|
||||
[[ "${ROUND_START}" != '0' ]] && echo "🔢 #${PR}: window seeded at round ${ROUND_START} → effective round ${ROUND}/${EFF_MAX_ROUNDS}"
|
||||
|
||||
# Effective watermark = what the agent has actually evaluated (its last
|
||||
@@ -2968,9 +2906,60 @@ jobs:
|
||||
EFF_WM="${CREATED_WM}"
|
||||
fi
|
||||
|
||||
if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" ]]; then
|
||||
echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human"
|
||||
fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry"
|
||||
if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" || "${BUDGET_SPENT}" == 'true' ]]; then
|
||||
# Budget spent: human PR released, bot PR paused. → qwen-autofix.md#af-159
|
||||
if [[ "${BUDGET_SPENT}" == 'true' && "${IS_BOT_AUTHOR}" != 'true' && "${BUDGET_HALTED}" != 'true' && ( -z "${FORCED_PR}" || "${EVENT_NAME}" != 'workflow_dispatch' ) ]]; then
|
||||
BUDGET_RT="${REARM_KEY}"
|
||||
[[ "${BUDGET_RT}" == "none" ]] && BUDGET_RT=''
|
||||
BUDGET_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${BUDGET_RT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-budget-reached -->"))
|
||||
| select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")"
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "🧪 DRY-RUN: would release ${TAKEOVER_LABEL} on #${PR} after its ${ROUND_BUDGET}-round budget"
|
||||
continue
|
||||
fi
|
||||
LIVE_LABELS_JSON="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null || echo '')"
|
||||
LIVE_LABELS="$(jq -r '[.labels[]?.name] | join(" ")' <<< "${LIVE_LABELS_JSON}" 2> /dev/null || echo '')"
|
||||
if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then
|
||||
SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')"
|
||||
fi
|
||||
if [[ -z "${LIVE_LABELS_JSON}" ]]; then
|
||||
echo "🧭 budget release skipped: label state unreadable (fail closed) on #${PR}"
|
||||
elif [[ " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* || " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* ]]; then
|
||||
echo "🧭 budget spent on #${PR} but consent changed (labels: ${LIVE_LABELS:-none}) — nothing to release"
|
||||
elif [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then
|
||||
echo "::warning::budget release skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
|
||||
else
|
||||
if [[ "${BUDGET_NOTICED}" == "0" ]]; then
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🏁 Takeover budget spent: %s round(s), %s attempt(s) used — the loop is releasing the PR now (removing the `%s` label); nothing here is waiting on a human. No-ops, errors, timeouts and crashes all count.\n\n<details>\n<summary>中文说明</summary>\n\n🏁 托管预算已用完:%s 轮预算、已用 %s 次尝试 —— 循环正在释放本 PR(移除 `%s` 标签),这里没有任何事情在等人处理。无改动、报错、超时或崩溃同样计数。\n\n</details>\n\n<!-- autofix-budget-reached -->' "${ROUND_BUDGET}" "${BUDGET_USED}" "${TAKEOVER_LABEL}" "${ROUND_BUDGET}" "${BUDGET_USED}" "${TAKEOVER_LABEL}")" \
|
||||
|| { echo "::warning::budget-spent notice failed for #${PR}; will retry next scan"; continue; }
|
||||
fi
|
||||
# REST, URI-encoded segment, 404-tolerant — every release site.
|
||||
if ! BUDGET_DEL_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${TAKEOVER_LABEL}" '$l|@uri')" 2>&1)"; then
|
||||
[[ "${BUDGET_DEL_ERR}" == *"HTTP 404"* ]] || echo "::warning::${TAKEOVER_LABEL} removal after budget failed for #${PR} — ${BUDGET_DEL_ERR}"
|
||||
else
|
||||
echo "🏷️ released ${TAKEOVER_LABEL} from #${PR} after a ${ROUND_BUDGET}-round budget"
|
||||
# A deferred release paused first: clear that escalation.
|
||||
if ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then
|
||||
[[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed after budget — ${NH_ERR}"
|
||||
fi
|
||||
fleet_row "${PR}" 'budget-spent' "budget ${BUDGET_USED}/${ROUND_BUDGET} spent - released"
|
||||
fi
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if [[ "${BUDGET_SPENT}" == 'true' ]]; then
|
||||
echo "🚧 #${PR}: ${BUDGET_USED}/${ROUND_BUDGET} budget spent — leaving for a human"
|
||||
fleet_row "${PR}" 'budget-spent' "budget ${BUDGET_USED}/${ROUND_BUDGET} spent - needs a human"
|
||||
else
|
||||
echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human"
|
||||
fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry"
|
||||
fi
|
||||
# A spent budget arrives with ROUND below the cap (no-ops never
|
||||
# advance it), so the notices quote the attempts instead.
|
||||
CAP_ROUND="${ROUND}"
|
||||
[[ "${BUDGET_SPENT}" == 'true' ]] && CAP_ROUND="${BUDGET_USED}"
|
||||
# A FORCED dispatch refused here answers OUT LOUD. Observed on
|
||||
# #7836: the fleet shepherd detected a merge conflict, posted
|
||||
# Full rationale → qwen-autofix.md#af-031
|
||||
@@ -2997,7 +2986,7 @@ jobs:
|
||||
fi
|
||||
if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then
|
||||
echo "::warning::cap-refused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
|
||||
elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n</details>\n\n<!-- takeover-cap-refused -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then
|
||||
elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n</details>\n\n<!-- takeover-cap-refused -->' "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then
|
||||
echo "::warning::cap-refused notice failed for #${PR}"
|
||||
fi
|
||||
fi
|
||||
@@ -3032,8 +3021,7 @@ jobs:
|
||||
# A bot-authored PR released from takeover returns to STANDARD
|
||||
# management (its ack says so) — it is still managed, so at the
|
||||
# strict cap it deserves the same notice + escalation label as
|
||||
# any managed PR (R4-5).
|
||||
IS_BOT_AUTHOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '((.author.login // "") == $ab)' <<< "${PR_META}")"
|
||||
# any managed PR (R4-5). IS_BOT_AUTHOR is computed once above.
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "🧪 DRY-RUN: would post cap-paused notice and apply ${NEEDS_HUMAN_LABEL} on #${PR}"
|
||||
else
|
||||
@@ -3085,9 +3073,9 @@ jobs:
|
||||
fi
|
||||
if [[ "${CAP_NOTICED}" == "0" ]]; then
|
||||
if [[ "${HAS_TAKEOVER}" == "true" ]]; then
|
||||
CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")"
|
||||
CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")"
|
||||
else
|
||||
CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"
|
||||
CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"
|
||||
fi
|
||||
if ! gh pr comment "${PR}" --repo "${REPO}" --body "${CAP_BODY}"; then
|
||||
echo "::warning::cap-paused notice failed for #${PR}; will retry next scan"
|
||||
@@ -3197,17 +3185,9 @@ jobs:
|
||||
if [[ "${CONV_STREAK}" -ge "${CONVERGENCE_BREAK_ROUNDS}" ]]; then
|
||||
CONV_PARKED='true'
|
||||
else
|
||||
# Release census = the park's own enumeration: a posted notice
|
||||
# HOLDS the park until the boundary itself moves (a trusted-
|
||||
# human response or a re-arm). Any clean round resets the
|
||||
# streak — including the review a maintainer push triggers via
|
||||
# synchronize — but must not silently release: the dedup
|
||||
# counts notices newer than the boundary, so a release-then-
|
||||
# re-trip would re-park under the stale notice with no fresh
|
||||
# one. If the signal resumes, the streak census above re-parks
|
||||
# loudly; this hold only keeps the quiet middle honest.
|
||||
# Mirrored live in prepare and in the report guard; keep in
|
||||
# lockstep. Full rationale → qwen-autofix.md#af-152
|
||||
# Release census = the park's own enumeration: a posted notice HOLDS the park until
|
||||
# the boundary moves. Mirrored in prepare and the report guard — keep in lockstep.
|
||||
# Full rationale → qwen-autofix.md#af-152
|
||||
CONV_NOTICES="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg since "${CONV_SINCE}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-convergence-break -->"))
|
||||
@@ -4361,8 +4341,8 @@ jobs:
|
||||
STALE='false'
|
||||
LIVE_MARKS="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[]
|
||||
| {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")"
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))?(?: run=([^ ]+))? -->") ] | .[]
|
||||
| {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), run: .[4], at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")"
|
||||
LIVE_REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-rearm -->"))
|
||||
@@ -4398,6 +4378,13 @@ jobs:
|
||||
LIVE_ROUND_START=$(( MAX_ROUNDS - 1 ))
|
||||
fi
|
||||
LIVE_MAX_ROUND="$(jq -r --arg key "${LIVE_REARM_KEY}" --argjson start "${LIVE_ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${LIVE_MARKS}")"
|
||||
LIVE_BUDGET_FLOOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") as $b | ($b | contains("<!-- takeover-ack engaged -->")) or ($b | contains("<!-- takeover-ack released -->")) or (($b | contains("<!-- autofix-rearm -->")) and (($b | contains("<!-- autofix-eval ")) | not))) | .created_at ] | max // ""' "${WORKDIR}/ic.json" 2> /dev/null || echo '')"
|
||||
LIVE_ROUND_BUDGET="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg f "${LIVE_BUDGET_FLOOR}" '[ .[] | select((.user.login // "") == $ab) | select((.created_at // "") >= $f) | ((.body // "") | [ scan("<!-- autofix-round-budget ([1-9][0-9]?) -->") ] | .[] | .[0]) ] | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')"
|
||||
LIVE_ATTEMPTS="$(jq -r --arg f "${LIVE_BUDGET_FLOOR}" '[.[] | select(.win != "none" and .win >= $f) | (.run // "\(.ts)|\(.round)")] | unique | length' <<< "${LIVE_MARKS}" 2> /dev/null || echo 0)"
|
||||
[[ "${LIVE_ATTEMPTS}" =~ ^[0-9]+$ ]] || LIVE_ATTEMPTS=0
|
||||
LIVE_BUDGET_SPENT=false
|
||||
[[ "${LIVE_ROUND_BUDGET}" -gt 0 && "${LIVE_ATTEMPTS}" -ge "${LIVE_ROUND_BUDGET}" ]] && LIVE_BUDGET_SPENT=true
|
||||
[[ "${LIVE_ROUND_BUDGET}" =~ ^[0-9]{1,2}$ && ( "${LIVE_TAKEOVER}" == 'true' || ( "${LIVE_AUTHOR}" == "${AUTOFIX_BOT}" && "${LIVE_BUDGET_SPENT}" == 'true' ) ) ]] || LIVE_ROUND_BUDGET=0
|
||||
# The head a sibling last judged, mirrored from the scan's RED_HEAD
|
||||
# parse. A no-op sibling records this marker while leaving BOTH ts and
|
||||
# round UNCHANGED — so the watermark/round triggers below never fire,
|
||||
@@ -4412,7 +4399,7 @@ jobs:
|
||||
# the maintainer asked for a fresh start, so a queued old-window job
|
||||
# must discard instead of finishing and stamping an old-sequence
|
||||
# marker into the new window. Conflicts stay actionable.
|
||||
if [[ "${STALE}" != "true" && "${WINDOW:-none}" != "${LIVE_REARM_KEY}" && "${CONFLICT}" != "true" ]]; then
|
||||
if [[ "${STALE}" != "true" && "${WINDOW:-none}" != "${LIVE_REARM_KEY}" && ( "${CONFLICT}" != "true" || "${LIVE_ROUND_BUDGET}" -gt 0 ) ]]; then
|
||||
STALE='true'
|
||||
echo "🫥 window superseded: selected under key '${WINDOW:-none}' but the live window is '${LIVE_REARM_KEY}' (re-armed while queued) — discarding without action or marker"
|
||||
fi
|
||||
@@ -4467,6 +4454,10 @@ jobs:
|
||||
STALE='true'
|
||||
echo "⛔ live round ${ROUND} already at MAX_ROUNDS (${MAX_ROUNDS}) — discarding without action or marker"
|
||||
fi
|
||||
if [[ "${STALE}" != "true" && "${LIVE_ROUND_BUDGET}" -gt 0 && "${LIVE_ATTEMPTS}" -ge "${LIVE_ROUND_BUDGET}" ]]; then
|
||||
STALE='true'
|
||||
echo "⛔ attempt budget spent (${LIVE_ATTEMPTS}/${LIVE_ROUND_BUDGET}) — discarding without action or marker"
|
||||
fi
|
||||
# Growth brake: measure the PR's net size (insertions minus
|
||||
# deletions vs the merge base), split into test lines and source
|
||||
# Full rationale → qwen-autofix.md#af-043
|
||||
@@ -4509,27 +4500,18 @@ jobs:
|
||||
NET_SRC=$(( NET_TOTAL - NET_TEST ))
|
||||
[[ "${NET_MEASURED}" != 'true' ]] &&
|
||||
echo "📏 growth measurement UNAVAILABLE this round (no merge base or shadowed base) — brake skipped, no anchor written"
|
||||
# The marker's window field is spelled `key=`, NOT `win=`: this
|
||||
# marker can legitimately carry a different window key than its
|
||||
# Full rationale → qwen-autofix.md#af-045
|
||||
# Window field spelled `key=`, NOT `win=`. → qwen-autofix.md#af-045
|
||||
BASE_UPD_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-base-updated"))
|
||||
| .created_at ] | max // ""' "${WORKDIR}/ic.json")"
|
||||
# The instant THIS round's net was measured. Stamped into the
|
||||
# growth-now marker so the census filters on measurement time,
|
||||
# not the marker comment's created_at.
|
||||
# Full rationale → qwen-autofix.md#af-118
|
||||
# Growth-now marker carries the measurement instant. → qwen-autofix.md#af-118
|
||||
MEASURED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
[[ "${NET_MEASURED}" == 'true' ]] && echo "measured_at=${MEASURED_AT}" >> "${GITHUB_OUTPUT}"
|
||||
# NOTE (#9114 R2-8/R6-3): re-anchoring on an EXTERNAL head move (an
|
||||
# author push) is deliberately NOT done here.
|
||||
# NOTE (#9114 R2-8/R6-3): an EXTERNAL head move is NOT re-anchored.
|
||||
# Full rationale → qwen-autofix.md#af-119
|
||||
GROWTH_NOW_CUTOFF="${BASE_UPD_AT}"
|
||||
# KNOWN RESIDUAL (#9114): this sibling read still filters on the
|
||||
# comment's created_at, not a prepare-time measured= like the
|
||||
# growth-now read below.
|
||||
# Full rationale → qwen-autofix.md#af-120
|
||||
# KNOWN RESIDUAL (#9114): filters on created_at, not measured=. → qwen-autofix.md#af-120
|
||||
GROWTH_BASELINE="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" --arg baseupd "${BASE_UPD_AT}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
|
||||
| [ scan("<!-- autofix-growth-base src=(-?[0-9]+) test=(-?[0-9]+) key=([^ ]+) -->") ] | .[]
|
||||
@@ -4723,7 +4705,7 @@ jobs:
|
||||
gsub("^(?:(?:\\s|<!--[\\s\\S]*?(?:-->|$)|\\p{Cf}))+"; ""; "s")
|
||||
| startswith("**[Critical]**");
|
||||
([ ($ic | add)[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[]
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))?(?: run=([^ ]+))? -->") ] | .[]
|
||||
| {ts: .[0], acted: .[1], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]
|
||||
| map(select(.win == $key) | select(.ts != "9999-12-31T23:59:59Z"))
|
||||
| sort_by(.at)) as $ms
|
||||
@@ -5110,7 +5092,7 @@ jobs:
|
||||
PRIOR_TIMEOUTS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-eval "))
|
||||
| select(([ ((.body // "") | scan("<!-- autofix-eval ts=[^ ]+ acted=[^ ]+ round=[0-9]+(?: win=([^ ]+))? -->")) ] | map(.[0] // "none")) as $wins
|
||||
| select(([ ((.body // "") | scan("<!-- autofix-eval ts=[^ ]+ acted=[^ ]+ round=[0-9]+(?: win=([^ ]+))?(?: run=[^ ]+)? -->")) ] | map(.[0] // "none")) as $wins
|
||||
| ($wins | length) > 0 and (($wins | last) == $key))
|
||||
] | sort_by(.created_at)
|
||||
| map((.body | gsub("\r"; "") | split("\n")[0]))
|
||||
@@ -5159,7 +5141,7 @@ jobs:
|
||||
# ${RUNNER_TEMP}/qwen-bin ahead of /usr/bin here — pin PATH from
|
||||
# the stage-time capture BEFORE the first command word resolves
|
||||
# (the R6-3 doctrine the sibling PAT steps apply; the loop
|
||||
# re-pins from the same capture). Full rationale → af-149
|
||||
# re-pins from the same capture). Full rationale → qwen-autofix.md#af-149
|
||||
export PATH="${TRUSTED_PATH}"
|
||||
# The gh calls in this step (deep-link lookup, comment upsert) and
|
||||
# the heartbeat loop launched here all carry the bot PAT: take the
|
||||
@@ -6399,18 +6381,8 @@ jobs:
|
||||
if [[ -n "${MAIN_HEAD_R}" && -n "${REPORT_HEAD}" ]]; then
|
||||
CMP_R="$(gh api "repos/${REPO}/compare/${MAIN_HEAD_R}...${REPORT_HEAD}" --jq '.status' 2> /dev/null || echo '')"
|
||||
if [[ "${CMP_R}" == 'behind' || "${CMP_R}" == 'diverged' ]]; then
|
||||
# Review-in-flight hold for the loop's OWN report-time
|
||||
# head move (#10110), fail-open on probe errors. Wider
|
||||
# than the scan's dispatch gate (#8888), which holds
|
||||
# only on IN_PROGRESS now that a queued review can sit
|
||||
# out a whole closed `ecs-review` window; a hold here
|
||||
# only defers one stale-base merge the next round
|
||||
# retries. The probe sees LIFECYCLE runs only: a
|
||||
# command run executes against the base branch, so its
|
||||
# review-pr check never shows under this PR's rollup,
|
||||
# and the runs fallback is event-scoped to
|
||||
# pull_request_target — an in-flight command review
|
||||
# does NOT hold the refresh (af-155).
|
||||
# Review-in-flight hold for the report-time head move (#10110, af-155):
|
||||
# LIFECYCLE runs only, and fail-open on probe errors.
|
||||
# Full rationale → qwen-autofix.md#af-155
|
||||
ROLLUP_R="$(gh pr view "${PR}" --repo "${REPO}" --json statusCheckRollup --jq '.statusCheckRollup // []' 2> /dev/null || echo '[]')"
|
||||
REVIEW_LIVE_R="$(jq -r '
|
||||
@@ -6535,7 +6507,7 @@ jobs:
|
||||
[.[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-eval "))
|
||||
| select(
|
||||
([ ((.body // "") | scan("<!-- autofix-eval ts=[^ ]+ acted=[^ ]+ round=[0-9]+(?: win=([^ ]+))? -->")) ] | map(.[0] // "none")) as $wins
|
||||
([ ((.body // "") | scan("<!-- autofix-eval ts=[^ ]+ acted=[^ ]+ round=[0-9]+(?: win=([^ ]+))?(?: run=[^ ]+)? -->")) ] | map(.[0] // "none")) as $wins
|
||||
| ($wins | length) > 0 and (($wins | last) == $win))]
|
||||
| sort_by(.created_at) | .[]
|
||||
| (.body | gsub("\r"; "") | split("\n")[0])' <<< "${COMMENTS_JSON}" 2> /dev/null || true)"
|
||||
@@ -6717,7 +6689,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
|
||||
echo
|
||||
echo "<!-- autofix-eval ts=${MARK_TS} acted=false round=${MARK_ROUND} win=${WINDOW:-none} -->"
|
||||
echo "<!-- autofix-eval ts=${MARK_TS} acted=false round=${MARK_ROUND} win=${WINDOW:-none} run=${GITHUB_RUN_ID} -->"
|
||||
# Per-round growth history the census counts — same marker
|
||||
# the push/no-op report paths write, so an over-budget
|
||||
# round that timed out or was gate-rejected is not a gap. run=
|
||||
@@ -6741,7 +6713,11 @@ jobs:
|
||||
echo "<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->"
|
||||
fi
|
||||
} > "${WORKDIR}/report.md"
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}"
|
||||
# The handoff is the ONLY carrier for an error/timeout/crash round,
|
||||
# so it retries like the re-arm ack: one lost post is a free attempt.
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" \
|
||||
|| { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md"; } \
|
||||
|| echo "::warning::Failed to post handoff comment on PR #${PR} after one retry — that round was not recorded"
|
||||
fi
|
||||
# A failed round must not lose verified deferred findings: the
|
||||
# agent's analysis is independent of whether this round's commit
|
||||
@@ -6817,18 +6793,7 @@ jobs:
|
||||
if: |-
|
||||
${{ always() && steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }}
|
||||
env:
|
||||
# Startup-channel pins, the gate steps' doctrine: BASH_ENV is
|
||||
# sourced by bash at process STARTUP, before the launch below,
|
||||
# and SHELLOPTS is the sibling option-import channel — a
|
||||
# body-side unset is one hop late, so both are pinned empty at
|
||||
# step level, which outranks any $GITHUB_ENV plant. The LD_*
|
||||
# family is mapped by ld.so at startup the same way. These pins
|
||||
# protect the parent shell that executes the launch; the
|
||||
# BASH_FUNC_<name>%% channel no env: block can enumerate — the
|
||||
# attacker chooses the name, and bash imports the entries as
|
||||
# functions at startup even under --norc, ahead of builtins and
|
||||
# PATH — is closed by the env -i clean child below (R9-1), the
|
||||
# gate's R6-4 pattern.
|
||||
# Startup-channel pins: BASH_ENV/SHELLOPTS/LD_* at step level, BASH_FUNC via the env -i child.
|
||||
# Full rationale → qwen-autofix.md#af-149
|
||||
BASH_ENV: ''
|
||||
SHELLOPTS: ''
|
||||
|
||||
@@ -995,6 +995,42 @@ jobs:
|
||||
fi
|
||||
exit 0
|
||||
|
||||
- name: 'Provision review linters (actionlint + shellcheck)'
|
||||
if: "steps.context.outputs.should_run == 'true'"
|
||||
# The review's deterministic script-lint gate spawns actionlint and
|
||||
# shellcheck from PATH; without them every workflow/shell PR caps as
|
||||
# unreviewed and the real-binary regressions never run (PR #16001
|
||||
# review). Install through the repo's own pinned, SHA-256-verified
|
||||
# installer and promote the fresh job-scoped tool dirs onto
|
||||
# GITHUB_PATH — prepended, so the verified bytes shadow anything a
|
||||
# shared runner left on PATH. The install is unconditional (no
|
||||
# `command -v` shortcut): a pre-existing binary is exactly the plant
|
||||
# the capture-tools step refuses to trust. Failure degrades, not
|
||||
# blocks: the gate caps the review as unreviewed, disclosed.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
run: |-
|
||||
node --input-type=module -e '
|
||||
import { execSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { getLinters, getLinterTempDir } from "./scripts/lint.js";
|
||||
const tempDir = getLinterTempDir();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const linters = getLinters();
|
||||
for (const name of ["actionlint", "shellcheck"]) {
|
||||
execSync(linters[name].installer, { stdio: "inherit" });
|
||||
// Verify the install produced its binary BEFORE promoting the
|
||||
// dir: a silent no-op install must fail this step visibly
|
||||
// (continue-on-error tolerates it, the UI shows it), not
|
||||
// degrade to a review lane without linters (PR #16001 F2).
|
||||
const bin = tempDir + "/" + name + "/" + name;
|
||||
if (!existsSync(bin)) throw new Error(name + " installer produced no binary at " + bin);
|
||||
appendFileSync(process.env.GITHUB_PATH, tempDir + "/" + name + "\n");
|
||||
}
|
||||
console.log("PROVISIONED actionlint + shellcheck into " + tempDir);
|
||||
'
|
||||
|
||||
- name: 'Prepare review scratch directory'
|
||||
id: 'review_scratch'
|
||||
if: "steps.context.outputs.should_run == 'true'"
|
||||
|
||||
@@ -540,8 +540,12 @@ jobs:
|
||||
# R6-3).
|
||||
local cands cts cauthor cage cperm reads=0
|
||||
cands="$(jq -r --arg tc "${TAKEOVER_COMMAND}" --arg rc "${RETRY_COMMAND}" '
|
||||
[ .[] | select(((.body // "") | gsub("^\\s+|\\s+$"; "")) == $tc
|
||||
or ((.body // "") | gsub("^\\s+|\\s+$"; "")) == $rc)
|
||||
[ .[] | ((.body // "") | gsub("^\\s+|\\s+$"; "")) as $b
|
||||
| ($b | ltrimstr($tc + " for ")) as $fn
|
||||
| ($b | ltrimstr($tc + " from ")) as $sn
|
||||
| select($b == $tc or $b == $rc
|
||||
or ($fn != $b and ($fn | test("^[0-9]{1,2}$")) and (($fn | tonumber) >= 1))
|
||||
or ($sn != $b and ($sn | test("^[0-9]{1,2}$"))))
|
||||
| {ts: (.created_at // ""), login: (.user.login // "")} ]
|
||||
| group_by(.login) | map(max_by(.ts)) | sort_by(.ts) | reverse | .[] | "\(.ts)\t\(.login)"' "${ic}")"
|
||||
while IFS=$'\t' read -r cts cauthor; do
|
||||
|
||||
+316
-127
@@ -2263,7 +2263,13 @@ jobs:
|
||||
# empty -> the job broke before deciding (publish reports infra).
|
||||
verdict: '${{ steps.run.outputs.verdict || steps.prepare.outputs.verdict || steps.pr.outputs.verdict }}'
|
||||
failure_phase: '${{ steps.prepare.outputs.failure_phase }}'
|
||||
agent_verdict: '${{ steps.run.outputs.agent_verdict }}'
|
||||
# Report-footer attribution: the configured model, the runner's CLI
|
||||
# version, and the content hash of the base-pinned skill that ran.
|
||||
# Empty when the run step never reached its output writes; the
|
||||
# publisher then keeps its generic signature.
|
||||
model: '${{ steps.run.outputs.model }}'
|
||||
cli_version: '${{ steps.run.outputs.cli_version }}'
|
||||
skill_hash: '${{ steps.run.outputs.skill_hash }}'
|
||||
# Deterministic flakiness gate (#9125): `flaky` demotes the published
|
||||
# headline; every other value is informational. The summary is
|
||||
# gate-authored fixed text plus counters — never PR-controlled strings.
|
||||
@@ -2546,34 +2552,42 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
# Status comment with the live run link, upserted by marker so
|
||||
# re-runs reuse one comment; publish-verify rewrites the same
|
||||
# comment with the final report. Best-effort.
|
||||
# Status comment with the live run link; publish-verify rewrites
|
||||
# this same comment with the final report. Best-effort.
|
||||
MARKER='<!-- qwen-triage:verify -->'
|
||||
# A dedicated machine marker for the live status. Inferring the
|
||||
# lifecycle state from prose would misread a REPORT that merely
|
||||
# quotes the running sentence as a live status — and the next run
|
||||
# would then overwrite that report.
|
||||
STATUS_MARKER='<!-- qwen-triage:verify-state=running -->'
|
||||
# Which run the live status belongs to. Verify jobs are serialized
|
||||
# per PR, but their publishers queue on hosted runners, so the
|
||||
# previous round's status is often still "running" here. Taking it
|
||||
# over would put two rounds into one comment, and the later report
|
||||
# would overwrite the earlier one. A run reuses only its own status
|
||||
# (a re-run attempt keeps the run id).
|
||||
RUN_MARKER="<!-- qwen-triage:verify-run=${GITHUB_RUN_ID} -->"
|
||||
# Bodies that carry findings mark themselves. Selecting "newest
|
||||
# comment without the running marker" would pick a cancelled or
|
||||
# infra notice, silently dropping the last real round's findings.
|
||||
SUBSTANTIVE_MARKER='<!-- qwen-triage:verify-substantive -->'
|
||||
printf -v BODY '%s\n%s\n\n%s\n\n%s' \
|
||||
printf -v BODY '%s\n%s\n%s\n\n%s\n\n%s' \
|
||||
"$MARKER" \
|
||||
"$STATUS_MARKER" \
|
||||
"$RUN_MARKER" \
|
||||
"🔬 **Sandboxed verification is running** — [watch live progress]($RUN_URL). The report will be posted here when the run completes." \
|
||||
"🔬 **沙箱验证正在运行** —— [查看实时进度]($RUN_URL)。运行结束后验证报告会发布在这里。"
|
||||
# Marker lookup, shared with publish-verify: only BOT-OWNED
|
||||
# comments whose body STARTS with the marker count. Any user can
|
||||
# paste the marker into a comment of their own; matching on
|
||||
# `contains` anywhere would make the bot try to PATCH a stranger's
|
||||
# comment (403) and strand the run. `stale` marks a marker comment
|
||||
# that is a previous round's terminal report rather than a live
|
||||
# status.
|
||||
# comment (403) and strand the run.
|
||||
# Fail CLOSED on an identity lookup failure: an empty bot login
|
||||
# used to widen the filter to every user's comments, so one
|
||||
# transient API error could point the bot at an attacker's comment.
|
||||
# Markers count only in the header, the lines before the first
|
||||
# blank line, where they are written: a report or a notice can
|
||||
# quote them further down.
|
||||
if ! BOT_LOGIN="$(gh api user --jq '.login')" || [ -z "$BOT_LOGIN" ]; then
|
||||
echo "::warning::Could not resolve the bot identity; posting a fresh status comment instead of reusing one."
|
||||
BOT_LOGIN=''
|
||||
@@ -2583,41 +2597,38 @@ jobs:
|
||||
EXISTING_META="$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
--method GET --paginate -F per_page=100 |
|
||||
jq -rs --arg m "$MARKER" --arg s "$STATUS_MARKER" --arg sub "$SUBSTANTIVE_MARKER" --arg bot "$BOT_LOGIN" \
|
||||
jq -rs --arg m "$MARKER" --arg s "$STATUS_MARKER" --arg run "$RUN_MARKER" --arg sub "$SUBSTANTIVE_MARKER" --arg bot "$BOT_LOGIN" \
|
||||
'[.[][] | select((.body | startswith($m)) and .user.login == $bot)] as $mine
|
||||
| ($mine | last) as $latest
|
||||
| ($mine | map(select(.body | contains($sub))) | last) as $report
|
||||
| if $latest == null then ""
|
||||
else "\($latest.id)\t\($latest.body | contains($s))\t\($report.id // "")" end'
|
||||
| ($mine | map(select(.body | split("\n\n")[0] | split("\n") | any(.[]; . == $s) and any(.[]; . == $run))) | last) as $own
|
||||
| ($mine | map(select(.body | split("\n\n")[0] | split("\n") | any(.[]; . == $sub))) | last) as $report
|
||||
| "\($own.id // "")\t\($report.id // "")"'
|
||||
)" || EXISTING_META=''
|
||||
fi
|
||||
EXISTING_ID="${EXISTING_META%%$'\t'*}"
|
||||
OWN_ID="${EXISTING_META%%$'\t'*}"
|
||||
# Snapshot the newest SUBSTANTIVE report, not simply the newest
|
||||
# marker comment: after a real report followed by a weak notice
|
||||
# (cancelled / infra), snapshotting the notice would drop the prior
|
||||
# round's unresolved findings from the follow-up table.
|
||||
REPORT_ID="${EXISTING_META##*$'\t'}"
|
||||
if [ -n "$REPORT_ID" ] && [ "$REPORT_ID" != "$EXISTING_META" ]; then
|
||||
REPORT_ID="${EXISTING_META#*$'\t'}"
|
||||
if [ -n "$REPORT_ID" ]; then
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$REPORT_ID" \
|
||||
--jq '.body' > "$RUNNER_TEMP/verify-context/previous-report.md" 2>/dev/null ||
|
||||
rm -f "$RUNNER_TEMP/verify-context/previous-report.md"
|
||||
fi
|
||||
# Only reuse the marker comment when it is a live status: replacing
|
||||
# a previous round's REPORT with "running" would destroy evidence
|
||||
# that a cancelled or infra-failed re-run then never restores.
|
||||
case "$EXISTING_META" in
|
||||
*$'\t'true$'\t'*)
|
||||
gh api --method PATCH \
|
||||
"repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING_ID" \
|
||||
-f body="$BODY" >/dev/null ||
|
||||
echo "::warning::Failed to update verify status comment; continuing." >&2
|
||||
;;
|
||||
*)
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
-f body="$BODY" >/dev/null ||
|
||||
echo "::warning::Failed to post verify status comment; continuing." >&2
|
||||
;;
|
||||
esac
|
||||
# Anything that is not this run's own live status gets a fresh
|
||||
# comment: replacing a previous round's REPORT with "running" would
|
||||
# destroy evidence that a cancelled or infra-failed re-run then
|
||||
# never restores.
|
||||
if [ -n "$OWN_ID" ]; then
|
||||
gh api --method PATCH \
|
||||
"repos/$GITHUB_REPOSITORY/issues/comments/$OWN_ID" \
|
||||
-f body="$BODY" >/dev/null ||
|
||||
echo "::warning::Failed to update verify status comment; continuing." >&2
|
||||
else
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
-f body="$BODY" >/dev/null ||
|
||||
echo "::warning::Failed to post verify status comment; continuing." >&2
|
||||
fi
|
||||
echo "decision=run" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Install before checkout so PR-controlled .npmrc cannot affect npm.
|
||||
@@ -3219,7 +3230,7 @@ jobs:
|
||||
uses: 'actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
|
||||
with:
|
||||
path: '${{ runner.temp }}/verify-maven-repo'
|
||||
key: "verify-maven-repo-${{ hashFiles('packages/sdk-java/*/pom.xml', 'packages/managed-agent-server/pom.xml') }}"
|
||||
key: "verify-maven-repo-${{ hashFiles('packages/sdk-java/*/pom.xml', 'packages/managed-agent-server/**/pom.xml') }}"
|
||||
restore-keys: 'verify-maven-repo-'
|
||||
|
||||
- name: 'Install and build PR app'
|
||||
@@ -4225,10 +4236,27 @@ jobs:
|
||||
chown -R root:root .qwen
|
||||
chmod -R a-w,a+rX .qwen
|
||||
|
||||
# 3. Fake artifact dirs planted during the build: a zeroed
|
||||
# timestamp sorts AHEAD of the agent's real dir at collection
|
||||
# time. (A steered agent can still forge its own artifacts —
|
||||
# that residual is why the report is advisory, never a review.)
|
||||
# Attribution for the published report's footer, collected HERE —
|
||||
# after the re-pin above and BEFORE the agent starts. The workspace
|
||||
# parent directory stays node-owned, so during the agent era PR
|
||||
# code can replace the whole .qwen directory (mv + mkdir) without
|
||||
# touching the root-owned files inside it; a hash taken after the
|
||||
# agent could then name instructions the round never loaded. The
|
||||
# model is the lane's own config and the CLI is the root-owned
|
||||
# global install. --no-filters matters even here: prepare left
|
||||
# .git node-owned, and a planted clean filter would otherwise
|
||||
# choose what gets hashed — or run as root. Best-effort: an empty
|
||||
# value makes the publisher keep its generic signature instead of
|
||||
# posting a half-attributed footer. The timeout keeps a planted
|
||||
# shim from hanging the step.
|
||||
CLI_VERSION="$(timeout 30s qwen --version 2>/dev/null | head -1 | tr -d '[:space:]' | cut -c1-64 || true)"
|
||||
SKILL_HASH="$(timeout 30s git hash-object --no-filters .qwen/skills/verify-pr/SKILL.md 2>/dev/null | cut -c1-12 || true)"
|
||||
|
||||
# 3. Fake artifact dirs planted during the build: the publisher
|
||||
# takes the round that sorts last, so a plant dated after the
|
||||
# agent's real dir would headline the run. (A steered agent can
|
||||
# still forge its own artifacts — that residual is why the
|
||||
# report is advisory, never a review.)
|
||||
find tmp -maxdepth 2 -type d -name '*-verify-*' -exec rm -rf {} + 2>/dev/null || true
|
||||
# 4. A fresh agent home, created after the lifecycle processes are
|
||||
# gone: qwen loads user-scope file commands from $HOME/.qwen, and
|
||||
@@ -4672,24 +4700,20 @@ jobs:
|
||||
echo "::error::Failed to write the agent output stream (tee exit ${TEE_STATUS}); reporting an infrastructure failure rather than a pass."
|
||||
fi
|
||||
echo "verdict=$VERDICT" >> "$GITHUB_OUTPUT"
|
||||
# verdict.txt is read only by the publisher, from the uploaded
|
||||
# report's own directory: a second reader here picked its own
|
||||
# round directory and could name a different word.
|
||||
|
||||
# The skill writes its verdict as one word in verdict.txt. It is
|
||||
# output of a run that executed untrusted PR code, so allowlist it
|
||||
# instead of echoing it into workflow outputs.
|
||||
AGENT_VERDICT=''
|
||||
VERDICT_FILE="$(find tmp -maxdepth 2 -type f -path '*-verify-*/verdict.txt' 2>/dev/null | sort | head -1 || true)"
|
||||
if [ -n "$VERDICT_FILE" ]; then
|
||||
# Bound the read before the pipeline: with `head` closing early on
|
||||
# an oversized file, the producer takes SIGPIPE and `pipefail`
|
||||
# would abort the step instead of ignoring an unusable verdict.
|
||||
CANDIDATE="$(head -c 4096 "$VERDICT_FILE" | tr -d '[:space:]' | cut -c1-32)"
|
||||
case "$CANDIDATE" in
|
||||
merge-ready|findings|blocked|inconclusive) AGENT_VERDICT="$CANDIDATE" ;;
|
||||
*) echo "::warning::Unrecognized agent verdict in ${VERDICT_FILE}; ignoring." ;;
|
||||
esac
|
||||
fi
|
||||
echo "agent_verdict=$AGENT_VERDICT" >> "$GITHUB_OUTPUT"
|
||||
echo "verify verdict: $VERDICT agent: ${AGENT_VERDICT:-none} (exit $EXIT_CODE)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Emit the attribution collected before the agent started (see the
|
||||
# re-pin above) — the saved values, never re-derived here: the
|
||||
# agent era can replace the .qwen directory itself, so a post-agent
|
||||
# hash could name instructions the round never loaded.
|
||||
{
|
||||
echo "model=$(printf '%s' "${OPENAI_MODEL:-}" | tr -d '\r\n' | cut -c1-200)"
|
||||
echo "cli_version=$CLI_VERSION"
|
||||
echo "skill_hash=$SKILL_HASH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "verify verdict: $VERDICT (exit $EXIT_CODE)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The gate log's authoritative copy lives root-owned inside the 0700
|
||||
# root-only home (/flake-gate/log), which no PR-controlled
|
||||
@@ -5169,8 +5193,9 @@ jobs:
|
||||
# per-PR group here would let a second /verify cancel a completed run's
|
||||
# pending publisher and drop its report entirely. Overlap between
|
||||
# publishers is instead made safe by the marker discipline below
|
||||
# (bot-owned + prefix match, live-status-only reuse, post-fresh
|
||||
# fallback), which is a correctness property rather than a scheduling one.
|
||||
# (bot-owned + prefix match, each run writes only into its own
|
||||
# comment, post-fresh fallback), which is a correctness property rather
|
||||
# than a scheduling one.
|
||||
concurrency:
|
||||
group: "${{ format('{0}-publish-verify-{1}', github.workflow, github.run_id) }}"
|
||||
cancel-in-progress: false
|
||||
@@ -5185,10 +5210,10 @@ jobs:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
# Sparse — the uploader, its helper dependency, and the root
|
||||
# package.json that marks the uploader's `.js` as ESM (without it the
|
||||
# uploader only parses on the Node versions that infer module syntax,
|
||||
# and this job pins no Node).
|
||||
# Sparse — the uploader, the artifact checker, their helpers, and the
|
||||
# root package.json that marks the uploader's `.js` as ESM (without it
|
||||
# the uploader only parses on the Node versions that infer module
|
||||
# syntax, and this job pins no Node).
|
||||
- name: 'Checkout the OSS publisher scripts'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
@@ -5202,6 +5227,8 @@ jobs:
|
||||
/package.json
|
||||
scripts/upload-aliyun-oss-assets.js
|
||||
scripts/release-script-utils.js
|
||||
scripts/verify-pr-check.mjs
|
||||
scripts/lib/
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
@@ -5276,7 +5303,9 @@ jobs:
|
||||
# cancellations. Verified by executing this step with both values.
|
||||
PR_NUMBER: '${{ needs.verify.outputs.pr_number || github.event.issue.number }}'
|
||||
VERDICT: '${{ needs.verify.outputs.verdict }}'
|
||||
AGENT_VERDICT: '${{ needs.verify.outputs.agent_verdict }}'
|
||||
VERIFY_MODEL: '${{ needs.verify.outputs.model }}'
|
||||
VERIFY_CLI_VERSION: '${{ needs.verify.outputs.cli_version }}'
|
||||
VERIFY_SKILL_HASH: '${{ needs.verify.outputs.skill_hash }}'
|
||||
FLAKE_VERDICT: '${{ needs.verify.outputs.flake_verdict }}'
|
||||
FLAKE_SUMMARY: '${{ needs.verify.outputs.flake_summary }}'
|
||||
SKIP_REASON: '${{ needs.verify.outputs.skip_reason }}'
|
||||
@@ -5311,6 +5340,13 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
BODY_FILE="${RUNNER_TEMP:-/tmp}/verify-comment.md"
|
||||
# Every body keeps the run marker the status step wrote, so this
|
||||
# job, re-run after it published, finds its own comment again
|
||||
# instead of posting the report twice.
|
||||
RUN_MARKER="<!-- qwen-triage:verify-run=${GITHUB_RUN_ID} -->"
|
||||
SUBSTANTIVE_MARKER='<!-- qwen-triage:verify-substantive -->'
|
||||
# Set only from the published report's own verdict.txt below.
|
||||
AGENT_VERDICT=''
|
||||
|
||||
# Same embedding discipline as publish-tmux: the report is untrusted
|
||||
# PR-run output, so it goes inside <details><pre><code> with &, <, >
|
||||
@@ -5614,23 +5650,26 @@ jobs:
|
||||
# find predicates enforce both), and any failure degrades to a
|
||||
# text-only comment rather than blocking the report. The
|
||||
# VERIFY_ASSETS_UPLOADER override exists as a test seam only.
|
||||
# $1 is the round directory the comment publishes: its images
|
||||
# illustrate its report, never another round's.
|
||||
EVIDENCE_SECTION=''
|
||||
collect_and_host_evidence() {
|
||||
local imgs=() f base safe seen=' ' hosted=0 total=0 skipped=0
|
||||
local round="${1:-}" imgs=() f base safe seen=' ' hosted=0 total=0 skipped=0
|
||||
local prefix="pr-assets/verify/pr${PR_NUMBER}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
local stage_dir="${RUNNER_TEMP:-/tmp}/verify-assets-stage"
|
||||
local uploader="${VERIFY_ASSETS_UPLOADER:-scripts/upload-aliyun-oss-assets.js}"
|
||||
[ -n "$round" ] || return 0
|
||||
# `|| true` on every find: the artifact download is
|
||||
# continue-on-error, so verify-results may not exist at all, and
|
||||
# a bare non-zero find under `pipefail` would abort the step
|
||||
# before the missing-report notice and the final upsert.
|
||||
total="$(find verify-results -type f -path '*/evidence/*.png' 2>/dev/null | wc -l | tr -d ' ' || true)"
|
||||
total="$(find "$round" -type f -path '*/evidence/*.png' 2>/dev/null | wc -l | tr -d ' ' || true)"
|
||||
[ -n "$total" ] && [ "$total" -gt 0 ] || return 0
|
||||
# Byte-exact size cap: find's -2M unit rounds file sizes UP to
|
||||
# whole MiB first, silently turning a documented 2 MB cap into a
|
||||
# 1 MiB one; the c (bytes) unit does not round.
|
||||
while IFS= read -r f; do imgs+=("$f"); done < <(
|
||||
find verify-results -type f -path '*/evidence/*.png' -size -2097153c 2>/dev/null | sort | head -8
|
||||
find "$round" -type f -path '*/evidence/*.png' -size -2097153c 2>/dev/null | sort | head -8
|
||||
)
|
||||
if [ "${#imgs[@]}" -eq 0 ]; then
|
||||
EVIDENCE_SECTION="_${total} evidence image(s) were produced but none passed the hosting caps (8 images, ≤2 MB each); see the run artifacts._"$'\n\n'
|
||||
@@ -5647,8 +5686,10 @@ jobs:
|
||||
*) continue ;;
|
||||
esac
|
||||
[ -n "${safe%.png}" ] || continue
|
||||
# Two artifact dirs can sanitize to the same name; the second
|
||||
# copy would silently overwrite the first and render twice.
|
||||
# Two images can sanitize to the same name (nested evidence/
|
||||
# dirs, or names that differ only in stripped characters); the
|
||||
# second copy would silently overwrite the first and render
|
||||
# twice.
|
||||
case "$seen" in *" $safe "*) continue ;; esac
|
||||
# Extension is attacker-choosable, and it is the enforcement
|
||||
# point for what OSS serves: ossutil derives the object's
|
||||
@@ -5733,20 +5774,21 @@ jobs:
|
||||
PARTIAL_EN='The verification run did not complete, so the phases below may be partial or missing entirely. **Advisory evidence for human reviewers — not a review, an approval, or a CI check.**'
|
||||
PARTIAL_ZH='本次验证运行未正常结束,下列内容可能不完整甚至缺失。仅作为评审证据,**不构成评审、批准或 CI 检查**。'
|
||||
|
||||
# Weak terminal notices (cancelled / infra / skipped / n-a) carry no
|
||||
# new evidence, so they must never overwrite a previous round's real
|
||||
# report — they only replace this run's own "running" status (see
|
||||
# the upsert below). Real outcomes (report, prepare-fail) upsert.
|
||||
WEAK_BODY=false
|
||||
# Every body opens with a header: the verify marker, this run's
|
||||
# marker and, on a body that carries findings, the substantive
|
||||
# marker, ended by a blank line. Every lookup reads markers only
|
||||
# from a header, so it is started once, here, and each branch below
|
||||
# appends to it.
|
||||
printf '%s\n%s\n' '<!-- qwen-triage:verify -->' "$RUN_MARKER" > "$BODY_FILE"
|
||||
|
||||
# The gate verdict travels via job outputs: it survives a later
|
||||
# agent-step failure, a job timeout, and a cancellation. A
|
||||
# recorded `flaky` must demote the headline in EVERY terminal
|
||||
# branch — these two used to post the neutral notice and silently
|
||||
# drop the demotion the download-failure branch already honors.
|
||||
if [ "${VERIFY_RESULT:-}" = "cancelled" ]; then
|
||||
WEAK_BODY=true
|
||||
{
|
||||
printf '%s\n\n' '<!-- qwen-triage:verify -->'
|
||||
printf '\n'
|
||||
if [ "${FLAKE_VERDICT:-}" = 'flaky' ]; then
|
||||
printf '**Sandboxed verification: ❌ not passed — non-deterministic tests (flakiness gate); job cancelled before the report** - [workflow run](%s)\n\n' "$RUN_URL"
|
||||
printf 'Flakiness gate: ❌ %s\n\n' "${FLAKE_SUMMARY:-changed test files returned different results across identical re-runs}"
|
||||
@@ -5763,11 +5805,10 @@ jobs:
|
||||
printf '</details>\n\n'
|
||||
fi
|
||||
printf '%s\n' '— _Qwen Code · sandboxed verification_'
|
||||
} > "$BODY_FILE"
|
||||
} >> "$BODY_FILE"
|
||||
elif [ "${VERIFY_RESULT:-}" != "success" ] || [ -z "${VERDICT:-}" ]; then
|
||||
WEAK_BODY=true
|
||||
{
|
||||
printf '%s\n\n' '<!-- qwen-triage:verify -->'
|
||||
printf '\n'
|
||||
if [ "${FLAKE_VERDICT:-}" = 'flaky' ]; then
|
||||
printf '**Sandboxed verification: ❌ not passed — non-deterministic tests (flakiness gate); the job then failed before the report** - [workflow run](%s)\n\n' "$RUN_URL"
|
||||
printf 'Flakiness gate: ❌ %s\n\n' "${FLAKE_SUMMARY:-changed test files returned different results across identical re-runs}"
|
||||
@@ -5784,14 +5825,13 @@ jobs:
|
||||
printf '</details>\n\n'
|
||||
fi
|
||||
printf '%s\n' '— _Qwen Code · sandboxed verification_'
|
||||
} > "$BODY_FILE"
|
||||
} >> "$BODY_FILE"
|
||||
elif [ "${VERDICT:-}" = "skipped" ] || [ "${VERDICT:-}" = "n/a" ]; then
|
||||
# These outcomes deliberately upload nothing, so their download
|
||||
# always "fails"; they must be answered before the
|
||||
# download-failure branch or their real reason is unreachable.
|
||||
WEAK_BODY=true
|
||||
{
|
||||
printf '%s\n\n' '<!-- qwen-triage:verify -->'
|
||||
printf '\n'
|
||||
if [ "${VERDICT:-}" = "skipped" ]; then
|
||||
printf '**Sandboxed verification: ⚠️ not run — skipped** - [workflow run](%s)\n\n' "$RUN_URL"
|
||||
printf 'Skipped because %s.\n\n' "${SKIP_REASON:-the PR was not in a verifiable state}"
|
||||
@@ -5806,21 +5846,20 @@ jobs:
|
||||
printf '</details>\n\n'
|
||||
fi
|
||||
printf '%s\n' '— _Qwen Code · sandboxed verification_'
|
||||
} > "$BODY_FILE"
|
||||
} >> "$BODY_FILE"
|
||||
elif [ "${DOWNLOAD_OUTCOME:-success}" != "success" ]; then
|
||||
# The verify job may have succeeded, but its artifact never
|
||||
# arrived (the download step is continue-on-error). Without this
|
||||
# branch the full-report path still runs and its scope paragraph
|
||||
# claims the A/B, the harnesses and the gates were delivered —
|
||||
# when nothing was.
|
||||
WEAK_BODY=true
|
||||
# The flake verdict travels via job outputs, independent of the
|
||||
# artifact — the gate's demotion contract ("reported as not
|
||||
# passed regardless of the agent verdict") must fire even when
|
||||
# the download did not, or a flaky PR gets a neutral
|
||||
# "results unavailable" notice instead of its ❌.
|
||||
{
|
||||
printf '%s\n\n' '<!-- qwen-triage:verify -->'
|
||||
printf '\n'
|
||||
if [ "${FLAKE_VERDICT:-}" = 'flaky' ]; then
|
||||
printf '**Sandboxed verification: ❌ not passed — non-deterministic tests (flakiness gate); result artifact unavailable** - [workflow run](%s)\n\n' "$RUN_URL"
|
||||
printf 'Flakiness gate: ❌ %s\n\n' "${FLAKE_SUMMARY:-changed test files returned different results across identical re-runs}"
|
||||
@@ -5837,11 +5876,8 @@ jobs:
|
||||
printf '</details>\n\n'
|
||||
fi
|
||||
printf '%s\n' '— _Qwen Code · sandboxed verification_'
|
||||
} > "$BODY_FILE"
|
||||
} >> "$BODY_FILE"
|
||||
elif [ -n "${PREPARE_FAILURE_PHASE:-}" ]; then
|
||||
# An infra-classified prepare failure says nothing about the PR,
|
||||
# so it must not overwrite a previous round's real report.
|
||||
[ "${VERDICT:-}" = 'infra-error' ] && WEAK_BODY=true
|
||||
PREPARE_LOG="$(find verify-results -name 'prepare.log' 2>/dev/null | head -1 || true)"
|
||||
# Initialised, not merely defaulted at use: an inherited value
|
||||
# would otherwise tell a reader that a single-shot `npm run
|
||||
@@ -5876,10 +5912,10 @@ jobs:
|
||||
# mis-attribution this commit set out to remove, pointed the
|
||||
# other way.
|
||||
{
|
||||
printf '%s\n\n' '<!-- qwen-triage:verify -->'
|
||||
# Reachable: the prepare step reports infra-error when the
|
||||
# registry was unreachable from the runner at failure time.
|
||||
if [ "${VERDICT:-}" = 'infra-error' ]; then
|
||||
printf '\n'
|
||||
printf '**Sandboxed verification: ⚠️ incomplete — infrastructure failure** - [workflow run](%s)\n\n' "$RUN_URL"
|
||||
printf '`%s` failed before any verification started, and `registry.npmjs.org` was unreachable from the runner at that moment — so this looks like an infrastructure incident rather than a problem with this PR. Re-running `@qwen-code /verify` may well succeed; the install log below is the place to confirm.\n\n' "$PREPARE_COMMAND"
|
||||
printf '<details>\n<summary>中文 — 判定:⚠️ 未完成 · 基础设施故障</summary>\n\n'
|
||||
@@ -5895,19 +5931,52 @@ jobs:
|
||||
fi
|
||||
emit_block 'Install/build log' "$PREPARE_LOG" 20000
|
||||
printf '%s\n' '— _Qwen Code · sandboxed verification_'
|
||||
} > "$BODY_FILE"
|
||||
} >> "$BODY_FILE"
|
||||
else
|
||||
collect_and_host_evidence
|
||||
# Pin the artifact-dir shape and sort: a bare -name search takes
|
||||
# whatever directory find visits first, which is unordered.
|
||||
REPORT="$(find verify-results -mindepth 2 -type f -path '*-verify-*/report.md' 2>/dev/null | sort | head -1 || true)"
|
||||
ASSERTIONS_FILE="$(find verify-results -mindepth 2 -type f -path '*-verify-*/assertions.json' 2>/dev/null | sort | head -1 || true)"
|
||||
# whatever directory find visits first, which is unordered. Rounds
|
||||
# are named pr<n>-verify-<YYYYMMDD>-<HHMMSS>, so the last in sort
|
||||
# order is the newest; an earlier round left beside it must not
|
||||
# headline the run. Depth 2 exactly: a report.md nested in a
|
||||
# round (scratch/) sorts past the round's own and would stand in
|
||||
# for it.
|
||||
REPORT="$(find verify-results -mindepth 2 -maxdepth 2 -type f -path '*-verify-*/report.md' 2>/dev/null | sort | tail -1 || true)"
|
||||
# The report's own directory is the round: its counts, its
|
||||
# verdict word and its images go with it, never another
|
||||
# directory's. This is the only reader of verdict.txt, which
|
||||
# untrusted PR code wrote.
|
||||
if [ -n "$REPORT" ]; then
|
||||
ROUND_DIR="$(dirname "$REPORT")"
|
||||
ASSERTIONS_FILE="$ROUND_DIR/assertions.json"
|
||||
[ -f "$ASSERTIONS_FILE" ] || ASSERTIONS_FILE=''
|
||||
AGENT_VERDICT="$(head -c 4096 "$ROUND_DIR/verdict.txt" 2>/dev/null | tr -d '[:space:]' | cut -c1-32 || true)"
|
||||
case "$AGENT_VERDICT" in
|
||||
merge-ready|findings|blocked|inconclusive) ;;
|
||||
*)
|
||||
echo "::warning::No usable verdict.txt beside the published report.md; the round has no agent verdict."
|
||||
AGENT_VERDICT=''
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# No report, so no verdict to trust: the newest round's counts
|
||||
# and images are shown only as partial evidence under the
|
||||
# process outcome.
|
||||
ASSERTIONS_FILE="$(find verify-results -mindepth 2 -maxdepth 2 -type f -path '*-verify-*/assertions.json' 2>/dev/null | sort | tail -1 || true)"
|
||||
if [ -n "$ASSERTIONS_FILE" ]; then
|
||||
ROUND_DIR="$(dirname "$ASSERTIONS_FILE")"
|
||||
else
|
||||
ROUND_DIR="$(find verify-results -mindepth 1 -maxdepth 1 -type d -name '*-verify-*' 2>/dev/null | sort | tail -1 || true)"
|
||||
fi
|
||||
fi
|
||||
collect_and_host_evidence "$ROUND_DIR"
|
||||
# Exact root-level path, never a find: the staging step writes
|
||||
# this name last as root, and a same-named file nested inside a
|
||||
# collected agent artifact dir must not shadow it.
|
||||
FLAKE_LOG='verify-results/flake-gate.log'
|
||||
ASSERT_LINE=''
|
||||
ASSERT_LINE_ZH=''
|
||||
GATES_LINE=''
|
||||
GATES_LINE_ZH=''
|
||||
if [ -n "$ASSERTIONS_FILE" ]; then
|
||||
# Numbers only — coerce anything non-numeric to 0 so untrusted
|
||||
# JSON cannot smuggle text into the comment body.
|
||||
@@ -5934,17 +6003,72 @@ jobs:
|
||||
read -r A_PASS A_FAIL A_TOTAL <<< "$ASSERT_META"
|
||||
ASSERT_LINE="Scripted assertions: ${A_PASS} passed · ${A_FAIL} failed · ${A_TOTAL} total"
|
||||
ASSERT_LINE_ZH="脚本断言:${A_PASS} 通过 · ${A_FAIL} 失败 · ${A_TOTAL} 总计"
|
||||
# Existing test suites run as targeted gates are counted
|
||||
# apart from the round's own assertions: folded in, a
|
||||
# workspace's thousands of tests made "N scripted
|
||||
# assertions" a measure of the repo, not of the round. Totals
|
||||
# only, so no agent-chosen suite name reaches the body; a
|
||||
# malformed list gets no line, and the checker refuses it.
|
||||
# Shape rules mirror the checker's, and one shared fixture
|
||||
# table pins the parity: a blank suite or a non-count skipped
|
||||
# (false/null) is refused, not defaulted, and counts stop at
|
||||
# 2^53-1, the last integer JSON literals parse to exactly.
|
||||
GATES_META="$(
|
||||
jq -r 'def count: type == "number" and . >= 0 and . == floor and . <= 9007199254740991;
|
||||
if (.gates | type) == "array" and (.gates | length) > 0
|
||||
and (.gates | all(type == "object" and (.suite | type) == "string" and (.suite | test("\\S")) and (.pass | count) and (.fail | count) and ((has("skipped") | not) or (.skipped | count))))
|
||||
then "\(.gates | length) \(.gates | map(.pass) | add | floor) \(.gates | map(.fail) | add | floor) \(.gates | map(.skipped // 0) | add | floor) \(.gates | map(select(.skipped != null)) | length)"
|
||||
else empty end' "$ASSERTIONS_FILE" 2>/dev/null || true
|
||||
)"
|
||||
if [ -n "$GATES_META" ]; then
|
||||
read -r G_SUITES G_PASS G_FAIL G_SKIP G_RECORDED <<< "$GATES_META"
|
||||
# Absent skipped is "not recorded", not a measured zero,
|
||||
# and a partial sum is not the run's total: the clause
|
||||
# prints only when every entry carries the field.
|
||||
G_SKIP_CLAUSE=''
|
||||
G_SKIP_CLAUSE_ZH=''
|
||||
if [ "$G_RECORDED" = "$G_SUITES" ]; then
|
||||
G_SKIP_CLAUSE=" · ${G_SKIP} skipped"
|
||||
G_SKIP_CLAUSE_ZH=" · ${G_SKIP} 跳过"
|
||||
fi
|
||||
GATES_LINE="Targeted gates (existing tests, not counted above): ${G_PASS} passed · ${G_FAIL} failed${G_SKIP_CLAUSE} across ${G_SUITES} suite(s)"
|
||||
GATES_LINE_ZH="定向门禁(已有测试,不计入上面的断言):${G_PASS} 通过 · ${G_FAIL} 失败${G_SKIP_CLAUSE_ZH},共 ${G_SUITES} 个套件"
|
||||
fi
|
||||
else
|
||||
echo "::warning::assertions.json missing or inconsistent; not treating it as evidence."
|
||||
fi
|
||||
fi
|
||||
# The checker local rounds run before posting, from this trusted
|
||||
# checkout, so both paths share one set of rules. It must pass on
|
||||
# the report's directory and read the verdict taken from it above.
|
||||
# Only its last stdout line is the summary: warnings above it can
|
||||
# carry agent-chosen evidence filenames, so they are never echoed.
|
||||
# node also exits 1 when the script cannot load; stderr says why.
|
||||
ARTIFACT_CHECK=''
|
||||
if [ "${VERDICT:-}" = 'pass' ] && [ -n "$REPORT" ] && [ -n "$ASSERT_LINE" ] && [ -n "${AGENT_VERDICT:-}" ]; then
|
||||
check_log="${RUNNER_TEMP:-/tmp}/verify-pr-check.log"
|
||||
check_status=0
|
||||
node "${VERIFY_PR_CHECK:-scripts/verify-pr-check.mjs}" "$(dirname "$REPORT")" > "$check_log" 2> "$check_log.err" || check_status=$?
|
||||
case "${check_status}:$(tail -n 1 "$check_log")" in
|
||||
"0: ok: verdict=${AGENT_VERDICT} ("*) ARTIFACT_CHECK=ok ;;
|
||||
'1: failed: verdict='*)
|
||||
ARTIFACT_CHECK=refused
|
||||
grep '^ error ' "$check_log" | head -20 | cut -c1-300 || true
|
||||
;;
|
||||
*)
|
||||
ARTIFACT_CHECK=error
|
||||
echo "::warning::The artifact check could not run (exit ${check_status}); not trusting the agent verdict."
|
||||
head -n 8 "$check_log.err" | cut -c1-300 || true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
# The agent's own verdict is only honoured for a run that
|
||||
# actually completed AND left consistent evidence: an early
|
||||
# `merge-ready` file must not headline a run that then timed out,
|
||||
# crashed, or produced no report/assertions. Otherwise the
|
||||
# headline comes from the process outcome.
|
||||
TRUST_AGENT_VERDICT=false
|
||||
if [ "${VERDICT:-}" = 'pass' ] && [ -n "$REPORT" ] && [ -n "$ASSERT_LINE" ]; then
|
||||
if [ "${VERDICT:-}" = 'pass' ] && [ -n "$REPORT" ] && [ -n "$ASSERT_LINE" ] && [ "$ARTIFACT_CHECK" = ok ]; then
|
||||
case "${AGENT_VERDICT:-}" in
|
||||
merge-ready) [ "$A_FAIL" = '0' ] && TRUST_AGENT_VERDICT=true ;;
|
||||
findings|blocked|inconclusive) TRUST_AGENT_VERDICT=true ;;
|
||||
@@ -5959,9 +6083,18 @@ jobs:
|
||||
# or produced no usable verdict says nothing about the PR and
|
||||
# gets incomplete/inconclusive, never a pass or a fail.
|
||||
if [ "$TRUST_AGENT_VERDICT" = true ]; then
|
||||
# Verdicts come from harness exits: `findings` with no failed
|
||||
# assertion is advisory, and the checker has already refused
|
||||
# failures none of which the ledger attributes to the PR.
|
||||
case "$AGENT_VERDICT" in
|
||||
merge-ready) QUAL='✅ passed'; QUAL_ZH='✅ 通过'; HEADLINE='merge-ready (agent verdict)'; HEADLINE_ZH='可合入(agent 判定)' ;;
|
||||
findings) QUAL='❌ not passed'; QUAL_ZH='❌ 不通过'; HEADLINE='findings reported (agent verdict)'; HEADLINE_ZH='报告了发现(agent 判定)' ;;
|
||||
findings)
|
||||
if [ "$A_FAIL" = '0' ]; then
|
||||
QUAL='✅ passed with findings'; QUAL_ZH='✅ 通过(有发现)'; HEADLINE='non-blocking findings, every scripted assertion passed (agent verdict)'; HEADLINE_ZH='非阻塞发现,脚本断言全部通过(agent 判定)'
|
||||
else
|
||||
QUAL='❌ not passed'; QUAL_ZH='❌ 不通过'; HEADLINE='findings reported (agent verdict)'; HEADLINE_ZH='报告了发现(agent 判定)'
|
||||
fi
|
||||
;;
|
||||
blocked) QUAL='❌ not passed'; QUAL_ZH='❌ 不通过'; HEADLINE='blocked (agent verdict)'; HEADLINE_ZH='阻塞(agent 判定)' ;;
|
||||
inconclusive) QUAL='⚠️ inconclusive'; QUAL_ZH='⚠️ 无法判定'; HEADLINE='the agent could not reach a conclusion'; HEADLINE_ZH='agent 未能得出结论' ;;
|
||||
esac
|
||||
@@ -5975,12 +6108,14 @@ jobs:
|
||||
esac
|
||||
if [ -n "${AGENT_VERDICT:-}" ]; then
|
||||
if [ "${VERDICT:-}" = 'pass' ]; then
|
||||
if [ -z "$REPORT" ]; then
|
||||
echo "::warning::Agent wrote verdict '${AGENT_VERDICT}' but it was not trusted (report.md missing); reporting the process outcome instead."
|
||||
elif [ -z "$ASSERT_LINE" ]; then
|
||||
# A word is only ever read beside a report, so the report
|
||||
# is present here.
|
||||
if [ -z "$ASSERT_LINE" ]; then
|
||||
echo "::warning::Agent wrote verdict '${AGENT_VERDICT}' but it was not trusted (assertions.json missing or inconsistent); reporting the process outcome instead."
|
||||
else
|
||||
elif [ "$AGENT_VERDICT" = 'merge-ready' ] && [ "${A_FAIL:-0}" != '0' ]; then
|
||||
echo "::warning::Agent wrote verdict '${AGENT_VERDICT}' but it was not trusted (assertions recorded ${A_FAIL:-?} failures); reporting the process outcome instead."
|
||||
else
|
||||
echo "::warning::Agent wrote verdict '${AGENT_VERDICT}' but it was not trusted (artifact check: ${ARTIFACT_CHECK}); reporting the process outcome instead."
|
||||
fi
|
||||
else
|
||||
echo "::warning::Agent wrote verdict '${AGENT_VERDICT}' but the run did not complete cleanly (process verdict '${VERDICT:-}'); reporting the process outcome instead."
|
||||
@@ -6031,19 +6166,27 @@ jobs:
|
||||
FLAKE_LINE_ZH='抖动门:⚠️ error — 发布时门判定缺失或无法识别,按 error 处理'
|
||||
;;
|
||||
esac
|
||||
# The merge-ready-with-failures case has its own paragraph below.
|
||||
CHECK_NOTE=''
|
||||
CHECK_NOTE_ZH=''
|
||||
if [ "$TRUST_AGENT_VERDICT" != true ] &&
|
||||
! { [ "${AGENT_VERDICT:-}" = 'merge-ready' ] && [ "${A_FAIL:-0}" != '0' ]; }; then
|
||||
case "$ARTIFACT_CHECK" in
|
||||
refused)
|
||||
CHECK_NOTE='its artifacts fail the consistency check (`verify-pr-check.mjs`), so that verdict was not trusted — the workflow run log lists the errors'
|
||||
CHECK_NOTE_ZH='其产物未通过一致性检查(`verify-pr-check.mjs`),因此该判定未被采信——检查的错误见工作流运行日志'
|
||||
;;
|
||||
error)
|
||||
CHECK_NOTE='the consistency check (`verify-pr-check.mjs`) could not run on its artifacts, so that verdict was not trusted — the workflow run log shows why'
|
||||
CHECK_NOTE_ZH='一致性检查(`verify-pr-check.mjs`)无法在其产物上运行,因此该判定未被采信——原因见工作流运行日志'
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [ -z "$REPORT" ]; then
|
||||
MISSING_REPORT_NOTE='No report.md was found in the run artifacts, so the report section is omitted — see the workflow run output.'
|
||||
echo "::warning::${MISSING_REPORT_NOTE}"
|
||||
fi
|
||||
# A run that timed out or crashed before writing report.md has
|
||||
# no findings to preserve: marking it substantive would let a
|
||||
# headline plus "no report found" overwrite the previous round's
|
||||
# real evidence, which is the opposite of the rule.
|
||||
if [ -z "$REPORT" ]; then
|
||||
WEAK_BODY=true
|
||||
fi
|
||||
{
|
||||
printf '%s\n' '<!-- qwen-triage:verify -->'
|
||||
# Substantive marker: this body carries findings, so a later
|
||||
# weak notice must not displace it as the follow-up round's
|
||||
# previous-report snapshot.
|
||||
@@ -6065,11 +6208,22 @@ jobs:
|
||||
if [ -n "$ASSERT_LINE" ]; then
|
||||
printf '%s\n\n' "$ASSERT_LINE"
|
||||
fi
|
||||
if [ -n "$GATES_LINE" ]; then
|
||||
printf '%s\n\n' "$GATES_LINE"
|
||||
# Advisory only: a gate can be red for a pre-existing main
|
||||
# failure, so this never refuses or demotes the verdict.
|
||||
if [ "$TRUST_AGENT_VERDICT" = true ] && [ "${G_FAIL:-0}" != '0' ]; then
|
||||
printf 'Gate totals do not decide the verdict: a gate red the PR causes belongs in the assertion ledger above — read that count before trusting the verdict.\n\n'
|
||||
fi
|
||||
fi
|
||||
if [ -n "$FLAKE_LINE" ]; then
|
||||
printf '%s\n\n' "$FLAKE_LINE"
|
||||
fi
|
||||
if [ "${AGENT_VERDICT:-}" = 'merge-ready' ] && [ "$TRUST_AGENT_VERDICT" != true ] && [ "${A_FAIL:-0}" != '0' ]; then
|
||||
printf 'The agent reported `merge-ready`, but `assertions.json` recorded %s failures, so that claim was not trusted — see the report for whether these are expected A/B control cells.\n\n' "$A_FAIL"
|
||||
printf 'The agent reported `merge-ready`, but `assertions.json` recorded %s failures, so that claim was not trusted: an expected A/B control red counts as a pass, so a recorded failure contradicts `merge-ready`.\n\n' "$A_FAIL"
|
||||
fi
|
||||
if [ -n "$CHECK_NOTE" ]; then
|
||||
printf 'The agent reported `%s`, but %s.\n\n' "$AGENT_VERDICT" "$CHECK_NOTE"
|
||||
fi
|
||||
if [ "${FLAKE_VERDICT:-}" = 'flaky' ]; then
|
||||
printf 'The deterministic flakiness gate re-ran the test files this PR changes and got different outcomes from identical runs%s. A test that can fail with no code changing lands as intermittent red on unrelated PRs, so this run is reported as not passed regardless of the agent verdict — the per-round matrix is in the flakiness gate log below.\n\n' "${AGENT_VERDICT:+ (agent verdict: \`${AGENT_VERDICT}\`)}"
|
||||
@@ -6090,11 +6244,20 @@ jobs:
|
||||
if [ -n "$ASSERT_LINE_ZH" ]; then
|
||||
printf '%s\n\n' "$ASSERT_LINE_ZH"
|
||||
fi
|
||||
if [ -n "$GATES_LINE_ZH" ]; then
|
||||
printf '%s\n\n' "$GATES_LINE_ZH"
|
||||
if [ "$TRUST_AGENT_VERDICT" = true ] && [ "${G_FAIL:-0}" != '0' ]; then
|
||||
printf '门禁合计不影响判定:PR 导致的门禁红灯应记入上面的断言账本——采信判定前请先看那个数。\n\n'
|
||||
fi
|
||||
fi
|
||||
if [ -n "$FLAKE_LINE_ZH" ]; then
|
||||
printf '%s\n\n' "$FLAKE_LINE_ZH"
|
||||
fi
|
||||
if [ "${AGENT_VERDICT:-}" = 'merge-ready' ] && [ "$TRUST_AGENT_VERDICT" != true ] && [ "${A_FAIL:-0}" != '0' ]; then
|
||||
printf 'agent 报告了 `merge-ready`,但 `assertions.json` 记录了 %s 个失败,因此该判定未被采信——请参阅报告确认这些是否为预期的 A/B 对照单元。\n\n' "$A_FAIL"
|
||||
printf 'agent 报告了 `merge-ready`,但 `assertions.json` 记录了 %s 个失败,因此该判定未被采信:预期中的 A/B 对照红灯应计为通过,所以记录在案的失败与 `merge-ready` 相矛盾。\n\n' "$A_FAIL"
|
||||
fi
|
||||
if [ -n "$CHECK_NOTE_ZH" ]; then
|
||||
printf 'agent 报告了 `%s`,但%s。\n\n' "$AGENT_VERDICT" "$CHECK_NOTE_ZH"
|
||||
fi
|
||||
if [ "${FLAKE_VERDICT:-}" = 'flaky' ]; then
|
||||
printf '确定性抖动门将本 PR 改动的测试文件原样重跑了多轮,得到了不一致的结果%s。一个在代码不变时也会失败的测试会以间歇性红灯落在无关的 PR 上,因此无论 agent 判定如何,本次运行按不通过报告——各轮结果矩阵见下方抖动门日志。\n\n' "${AGENT_VERDICT:+(agent 判定:\`${AGENT_VERDICT}\`)}"
|
||||
@@ -6115,45 +6278,71 @@ jobs:
|
||||
printf '%s' "$EVIDENCE_SECTION"
|
||||
fi
|
||||
printf 'Harness scripts and raw logs are in the workflow run artifacts (7-day retention).\n\n'
|
||||
printf '%s\n' '— _Qwen Code · sandboxed verification_'
|
||||
} > "$BODY_FILE"
|
||||
# Attribution footer, the same shape /review posts: model, CLI
|
||||
# version, and the content hash of the skill that ran. Only on
|
||||
# a body that actually carries a report and only when all three
|
||||
# workflow-owned values arrived — without a report the body
|
||||
# is only a notice, and a half-known identity keeps the
|
||||
# generic signature too.
|
||||
if [ -n "$REPORT" ] && [ -n "${VERIFY_MODEL:-}" ] && [ -n "${VERIFY_CLI_VERSION:-}" ] && [ -n "${VERIFY_SKILL_HASH:-}" ]; then
|
||||
printf '_— %s via Qwen Code /verify-pr (v%s; skill %s)_\n' "$VERIFY_MODEL" "$VERIFY_CLI_VERSION" "$VERIFY_SKILL_HASH"
|
||||
else
|
||||
printf '%s\n' '— _Qwen Code · sandboxed verification_'
|
||||
fi
|
||||
} >> "$BODY_FILE"
|
||||
fi
|
||||
|
||||
# Upsert by marker: a real outcome (report / prepare-fail) replaces
|
||||
# whatever carries the marker — the "running" status or an older
|
||||
# report. A WEAK_BODY notice only replaces this run's own "running"
|
||||
# status; if the marker comment is a previous round's real report,
|
||||
# post fresh so that report survives.
|
||||
# Write only into this run's own comment, found by its run marker:
|
||||
# the live status its verify job posted, or the body this job
|
||||
# already published if it is re-run. Anything else gets a fresh
|
||||
# comment. The newest marker comment may belong to another round —
|
||||
# a later round's live status, or an earlier round's report — and
|
||||
# replacing either would make one round overwrite the other.
|
||||
# Same ownership discipline as the resolve step: only BOT-OWNED
|
||||
# comments STARTING with the marker are candidates, so a marker
|
||||
# pasted by any user cannot divert the bot into PATCHing (and
|
||||
# failing on) someone else's comment.
|
||||
# Fail CLOSED on identity failure (an empty login previously
|
||||
# widened the filter to every user's comments), and match the
|
||||
# live-status MARKER rather than prose a report could quote.
|
||||
EXISTING='' EXISTING_RUNNING='false'
|
||||
# widened the filter to every user's comments), and read the
|
||||
# machine markers only from a header: a report, or a value a PR
|
||||
# run produced, can quote one further down.
|
||||
OWN_ID=''
|
||||
OWN_SUBSTANTIVE=''
|
||||
if ! BOT_LOGIN="$(gh api user --jq '.login')" || [ -z "$BOT_LOGIN" ]; then
|
||||
echo "::warning::Could not resolve the bot identity; posting a fresh comment instead of reusing one."
|
||||
BOT_LOGIN=''
|
||||
fi
|
||||
if [ -n "$BOT_LOGIN" ] && EXISTING_META="$(
|
||||
if [ -n "$BOT_LOGIN" ] && OWN_META="$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
--method GET \
|
||||
--paginate \
|
||||
-F per_page=100 \
|
||||
| jq -sr --arg bot "$BOT_LOGIN" \
|
||||
'[.[][] | select((.body | startswith("<!-- qwen-triage:verify -->")) and .user.login == $bot)] | last
|
||||
| if . == null then "" else "\(.id)\t\(.body | contains("<!-- qwen-triage:verify-state=running -->"))" end'
|
||||
| jq -sr --arg bot "$BOT_LOGIN" --arg run "$RUN_MARKER" --arg sub "$SUBSTANTIVE_MARKER" \
|
||||
'def header: split("\n\n")[0] | split("\n");
|
||||
[.[][] | select((.body | startswith("<!-- qwen-triage:verify -->")) and .user.login == $bot)
|
||||
| (.body | header) as $h | select(any($h[]; . == $run))
|
||||
| "\(.id)\t\(any($h[]; . == $sub))"]
|
||||
| last // empty'
|
||||
)"; then
|
||||
EXISTING="${EXISTING_META%%$'\t'*}"
|
||||
case "$EXISTING_META" in *$'\t'true) EXISTING_RUNNING='true' ;; esac
|
||||
OWN_ID="${OWN_META%%$'\t'*}"
|
||||
OWN_SUBSTANTIVE="${OWN_META#*$'\t'}"
|
||||
elif [ -n "$BOT_LOGIN" ]; then
|
||||
echo "::warning::Failed to look up existing verify comments; will create a new one."
|
||||
echo "::warning::Failed to look up this run's comment; will create a new one."
|
||||
fi
|
||||
# A notice never replaces a body that carries findings. Re-run on
|
||||
# its own, this job cannot download results (the artifact name
|
||||
# carries the attempt), so its "results unavailable" notice would
|
||||
# otherwise replace the round's report; it is posted beside it.
|
||||
BODY_SUBSTANTIVE="$(
|
||||
jq -Rrs --arg sub "$SUBSTANTIVE_MARKER" 'split("\n\n")[0] | split("\n") | any(.[]; . == $sub)' "$BODY_FILE" || true
|
||||
)"
|
||||
if [ "$OWN_SUBSTANTIVE" = true ] && [ "$BODY_SUBSTANTIVE" != true ]; then
|
||||
OWN_ID=''
|
||||
fi
|
||||
# A failed PATCH (comment deleted, or ownership changed under us)
|
||||
# must still leave a terminal report on the PR, never silence.
|
||||
if [ -n "$EXISTING" ] && { [ "$WEAK_BODY" = false ] || [ "$EXISTING_RUNNING" = 'true' ]; }; then
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING" -F body=@"$BODY_FILE" >/dev/null ||
|
||||
if [ -n "$OWN_ID" ]; then
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$OWN_ID" -F body=@"$BODY_FILE" >/dev/null ||
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -F body=@"$BODY_FILE" >/dev/null
|
||||
else
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -F body=@"$BODY_FILE" >/dev/null
|
||||
|
||||
@@ -38,7 +38,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
# The release-event path can be paused by setting the repository variable
|
||||
# RELEASE_CHROME_SYNC_PUBLISH=false; manual dispatch keeps working.
|
||||
if: |-
|
||||
@@ -61,6 +61,8 @@ jobs:
|
||||
is_dry_run: '${{ steps.vars.outputs.is_dry_run }}'
|
||||
should_publish: '${{ steps.vars.outputs.should_publish }}'
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
@@ -88,6 +90,7 @@ jobs:
|
||||
echo "should_publish=${should_publish}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
if: "${{ runner.environment != 'self-hosted' }}"
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -157,13 +160,15 @@ jobs:
|
||||
publish:
|
||||
needs: 'build'
|
||||
if: "${{ needs.build.outputs.is_dry_run == 'false' }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Download the package'
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
|
||||
@@ -7,9 +7,12 @@ on:
|
||||
paths:
|
||||
- '.github/workflows/release-managed-agent-reference.yml'
|
||||
- 'scripts/package-managed-agent-reference.mjs'
|
||||
- 'scripts/generate-managed-agent-configuration-reference.mjs'
|
||||
- 'scripts/contract-version-utils.js'
|
||||
- 'scripts/flyway-migration-utils.js'
|
||||
- 'scripts/tests/package-managed-agent-reference.test.mjs'
|
||||
- 'scripts/tests/managed-agent-upgrade.test.mjs'
|
||||
- 'scripts/tests/generate-managed-agent-configuration-reference.test.mjs'
|
||||
- 'packages/sdk-java/qwencode/**'
|
||||
- 'packages/managed-agent-server/**'
|
||||
- 'packages/core/src/managed-runtime/contracts/**'
|
||||
@@ -18,6 +21,8 @@ on:
|
||||
- 'packages/cli/src/serve/hosted-harness-contract.ts'
|
||||
- 'docs/users/managed-agent-reference-deployment.md'
|
||||
- 'docs/users/managed-agent-reference-deployment.zh-CN.md'
|
||||
- 'docs/users/managed-agent-configuration-reference.md'
|
||||
- 'docs/users/managed-agent-configuration-reference.zh-CN.md'
|
||||
- 'docs/users/hosted-workspace-recovery.md'
|
||||
- 'docs/users/hosted-workspace-recovery.zh-CN.md'
|
||||
- 'LICENSE'
|
||||
@@ -216,7 +221,7 @@ jobs:
|
||||
cache: "${{ runner.environment == 'github-hosted' && 'maven' || '' }}"
|
||||
cache-dependency-path: |-
|
||||
packages/sdk-java/qwencode/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
settings-path: '${{ runner.temp }}/setup-java-m2'
|
||||
|
||||
- name: 'Set up Maven (self-hosted)'
|
||||
@@ -313,8 +318,8 @@ jobs:
|
||||
mysql -uroot -proot \
|
||||
--execute="GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'%';"
|
||||
|
||||
- name: 'Test the packager'
|
||||
run: 'node --test scripts/tests/package-managed-agent-reference.test.mjs'
|
||||
- name: 'Test the packager and the upgrade command'
|
||||
run: 'node --test scripts/tests/package-managed-agent-reference.test.mjs scripts/tests/managed-agent-upgrade.test.mjs scripts/tests/generate-managed-agent-configuration-reference.test.mjs'
|
||||
|
||||
- name: 'Build Java components'
|
||||
run: |-
|
||||
@@ -325,12 +330,16 @@ jobs:
|
||||
# PR's build must never reach the host-shared ~/.m2.
|
||||
repo="${RUNNER_TEMP}/m2-repo"
|
||||
cp -aln "${HOME}/.m2/repository" "${repo}" 2>/dev/null || true
|
||||
rm -rf -- "${repo}/com/alibaba/qwencode-sdk" "${repo}/com/alibaba/qwen-managed-agent-server"
|
||||
rm -rf -- "${repo}/com/alibaba/qwencode-sdk" "${repo}/com/alibaba/"qwen-managed-agent-*
|
||||
MAVEN_ARGS="${MAVEN_ARGS} -Dmaven.repo.local=${repo}"
|
||||
fi
|
||||
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true clean install
|
||||
mvn --batch-mode --no-transfer-progress -f packages/managed-agent-server/pom.xml -DskipTests clean package
|
||||
|
||||
# The build wrote the configuration metadata the reference is made from.
|
||||
- name: 'Check the configuration reference'
|
||||
run: 'node scripts/generate-managed-agent-configuration-reference.mjs --check'
|
||||
|
||||
- name: 'Assemble reference distribution'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.version }}'
|
||||
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
cache: 'maven'
|
||||
cache-dependency-path: |
|
||||
packages/sdk-java/qwencode/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
|
||||
- name: 'Set up Node.js 22'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
@@ -127,6 +127,31 @@ jobs:
|
||||
node-version: '22'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: 'Repair the 0.26.3 ownership test fixture'
|
||||
if: "${{ env.RELEASE_TAG == 'v0.26.3' }}"
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
if [[ "${SOURCE_SHA}" != 'eeee1443a7e36ab22b2f1dcded8dec64b621a7ee' ]]; then
|
||||
echo 'The 0.26.3 test repair requires the original release commit.' >&2
|
||||
exit 1
|
||||
fi
|
||||
test_file='packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/Issue13180HardenedVerificationTest.java'
|
||||
patch='.qwen/managed-release-tools/scripts/patches/managed-runtime-v0.26.3-tests.patch'
|
||||
git diff --exit-code HEAD
|
||||
if [[ "$(git apply --numstat "${patch}" | cut -f3)" != "${test_file}" ]]; then
|
||||
echo 'The 0.26.3 repair must change only the ownership test.' >&2
|
||||
exit 1
|
||||
fi
|
||||
git apply --check "${patch}"
|
||||
git apply "${patch}"
|
||||
[[ "$(git diff --name-only HEAD)" == "${test_file}" ]]
|
||||
{
|
||||
echo '### Managed runtime 0.26.3 test repair'
|
||||
echo "Product source: ${SOURCE_SHA}; original tag unchanged."
|
||||
echo "Test-only patch blob: $(git hash-object "${patch}")"
|
||||
echo 'The complete SDK and server verification gates remain enabled.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: 'Build the matching CLI prerequisites'
|
||||
env:
|
||||
QWEN_SKIP_PREPARE: '1'
|
||||
@@ -147,8 +172,9 @@ jobs:
|
||||
- name: 'Verify the executable JAR and publication profile'
|
||||
run: |-
|
||||
mvn --batch-mode --no-transfer-progress -f packages/managed-agent-server/pom.xml clean verify -Pcentral-release -Dgpg.skip=true -DforkCount=1 -DreuseForks=false -Dproject.build.outputTimestamp="${BUILD_TIMESTAMP}"
|
||||
runtime_jar="packages/managed-agent-server/target/qwen-managed-agent-server-${CLI_VERSION}-exec.jar"
|
||||
runtime_jar="packages/managed-agent-server/server/target/qwen-managed-agent-server-${CLI_VERSION}-exec.jar"
|
||||
# Older release tags shipped the executable as the main artifact.
|
||||
if [[ ! -f "${runtime_jar}" ]]; then runtime_jar="packages/managed-agent-server/target/qwen-managed-agent-server-${CLI_VERSION}-exec.jar"; fi
|
||||
if [[ ! -f "${runtime_jar}" ]]; then runtime_jar="packages/managed-agent-server/target/qwen-managed-agent-server-${CLI_VERSION}.jar"; fi
|
||||
unzip -p "${runtime_jar}" META-INF/MANIFEST.MF | tr -d '\r' | grep -Fx 'Main-Class: org.springframework.boot.loader.launch.JarLauncher'
|
||||
node .qwen/managed-release-tools/scripts/managed-runtime-release.js --jar "${runtime_jar}" --version "${CLI_VERSION}" --out-dir dist/managed-runtime
|
||||
@@ -187,7 +213,10 @@ jobs:
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
[[ -n "${ALIYUN_OSS_ACCESS_KEY_ID}" && -n "${ALIYUN_OSS_ACCESS_KEY_SECRET}" ]]
|
||||
if [[ -z "${ALIYUN_OSS_ACCESS_KEY_ID}" || -z "${ALIYUN_OSS_ACCESS_KEY_SECRET}" ]]; then
|
||||
echo '::error::Both OSS credentials are required' >&2
|
||||
exit 1
|
||||
fi
|
||||
ossutil config -e "${ALIYUN_OSS_ENDPOINT}" -i "${ALIYUN_OSS_ACCESS_KEY_ID}" -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" -L EN -c "${RUNNER_TEMP}/managed-ossutilconfig"
|
||||
|
||||
- name: 'Recheck the immutable tag and existing publication bytes'
|
||||
|
||||
@@ -51,13 +51,18 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-sdk:
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
build-sdk:
|
||||
# This job bundles and packs with Node on either release architecture. Scheduling is gated by
|
||||
# the job's `if` below, not by this label.
|
||||
runs-on: ['self-hosted', 'linux', 'ecs-qwen-release-host']
|
||||
environment: 'production-release'
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: 'read'
|
||||
env:
|
||||
BUILD_SDK: 'true'
|
||||
if: |-
|
||||
${{
|
||||
${{ vars.ECS_RELEASE_HOST_RUNNER_ENABLED == 'true' &&
|
||||
github.repository == 'QwenLM/qwen-code' &&
|
||||
(
|
||||
github.event_name != 'release' ||
|
||||
@@ -68,23 +73,41 @@ jobs:
|
||||
)
|
||||
)
|
||||
}}
|
||||
permissions:
|
||||
contents: 'write'
|
||||
packages: 'write'
|
||||
id-token: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
outputs:
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
source_sha: '${{ steps.source.outputs.sha }}'
|
||||
release_tag: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
release_version: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
npm_tag: '${{ steps.version.outputs.NPM_TAG }}'
|
||||
previous_release_tag: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||
is_nightly: '${{ steps.vars.outputs.is_nightly }}'
|
||||
is_preview: '${{ steps.vars.outputs.is_preview }}'
|
||||
is_dry_run: '${{ steps.vars.outputs.is_dry_run }}'
|
||||
cli_source: '${{ steps.cli_source.outputs.mode }}'
|
||||
cli_version: "${{ steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION }}"
|
||||
artifact_id: '${{ steps.artifact.outputs.artifact-id }}'
|
||||
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set booleans for simplified logic'
|
||||
- name: 'Install npm 11'
|
||||
run: 'npm install --global [email protected]'
|
||||
|
||||
- &sdk_pin_source
|
||||
name: 'Pin SDK source'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
id: 'source'
|
||||
run: 'echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'
|
||||
|
||||
- &sdk_flags
|
||||
name: 'Set booleans for simplified logic'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
env:
|
||||
CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}'
|
||||
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
|
||||
@@ -109,22 +132,15 @@ jobs:
|
||||
fi
|
||||
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
package-manager-cache: false
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
- name: 'Install npm 11'
|
||||
run: 'npm install --global [email protected]'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
- &sdk_dependencies
|
||||
name: 'Install Dependencies'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
run: |-
|
||||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||||
|
||||
- name: 'Get the version'
|
||||
- &sdk_version
|
||||
name: 'Get the version'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
id: 'version'
|
||||
run: |
|
||||
VERSION_ARGS=()
|
||||
@@ -177,7 +193,9 @@ jobs:
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Set SDK package version (local only)'
|
||||
- &sdk_set_version
|
||||
name: 'Set SDK package version (local only)'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
run: |-
|
||||
@@ -185,7 +203,9 @@ jobs:
|
||||
# This is required for nightly/preview because npm does not allow re-publishing the same version.
|
||||
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version --no-workspaces-update
|
||||
|
||||
- name: 'Determine CLI source and version'
|
||||
- &sdk_cli_source
|
||||
name: 'Determine CLI source and version'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
id: 'cli_source'
|
||||
env:
|
||||
CLI_SOURCE_INPUT: '${{ github.event.inputs.cli_source }}'
|
||||
@@ -199,9 +219,10 @@ jobs:
|
||||
echo "Building SDK with CLI built from current branch/ref"
|
||||
fi
|
||||
|
||||
- name: 'Get CLI version from npm (for npm_latest mode)'
|
||||
- &sdk_cli_version_npm
|
||||
name: 'Get CLI version from npm (for npm_latest mode)'
|
||||
id: 'cli_version_npm'
|
||||
if: "steps.cli_source.outputs.mode == 'npm_latest'"
|
||||
if: "${{ env.BUILD_SDK == 'true' && (steps.cli_source.outputs.mode == 'npm_latest') }}"
|
||||
run: |
|
||||
CLI_VERSION=$(npm view @qwen-code/qwen-code version --tag=latest)
|
||||
if [[ -z "${CLI_VERSION}" ]]; then
|
||||
@@ -211,9 +232,10 @@ jobs:
|
||||
echo "CLI_VERSION=${CLI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Using latest stable CLI version from npm: ${CLI_VERSION}"
|
||||
|
||||
- name: 'Download CLI package from npm'
|
||||
- &sdk_download_cli
|
||||
name: 'Download CLI package from npm'
|
||||
id: 'cli_download'
|
||||
if: "steps.cli_source.outputs.mode == 'npm_latest'"
|
||||
if: "${{ env.BUILD_SDK == 'true' && (steps.cli_source.outputs.mode == 'npm_latest') }}"
|
||||
env:
|
||||
CLI_VERSION: '${{ steps.cli_version_npm.outputs.CLI_VERSION }}'
|
||||
run: |
|
||||
@@ -233,9 +255,10 @@ jobs:
|
||||
echo "CLI package contents:"
|
||||
ls -la "${CLI_TMP_DIR}/package/"
|
||||
|
||||
- name: 'Build CLI from source'
|
||||
- &sdk_build_cli
|
||||
name: 'Build CLI from source'
|
||||
id: 'cli_build'
|
||||
if: "steps.cli_source.outputs.mode == 'build_from_source'"
|
||||
if: "${{ env.BUILD_SDK == 'true' && (steps.cli_source.outputs.mode == 'build_from_source') }}"
|
||||
run: |
|
||||
# Build the CLI bundle from source
|
||||
echo "Building CLI from source..."
|
||||
@@ -255,33 +278,39 @@ jobs:
|
||||
echo "CLI bundle built successfully at ./dist/"
|
||||
ls -la ./dist/
|
||||
|
||||
- name: 'Configure Git User'
|
||||
- &sdk_git_user
|
||||
name: 'Configure Git User'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 'Build SDK'
|
||||
- &sdk_build
|
||||
name: 'Build SDK'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |-
|
||||
npm run build
|
||||
|
||||
- name: 'Bundle CLI into SDK (from npm)'
|
||||
if: "steps.cli_source.outputs.mode == 'npm_latest'"
|
||||
- &sdk_bundle_npm_cli
|
||||
name: 'Bundle CLI into SDK (from npm)'
|
||||
if: "${{ env.BUILD_SDK == 'true' && (steps.cli_source.outputs.mode == 'npm_latest') }}"
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
env:
|
||||
CLI_PACKAGE_PATH: '${{ steps.cli_download.outputs.CLI_TMP_DIR }}/package'
|
||||
run: |
|
||||
node scripts/bundle-cli-from-npm.js
|
||||
|
||||
- name: 'Bundle CLI into SDK (from source)'
|
||||
if: "steps.cli_source.outputs.mode == 'build_from_source'"
|
||||
- &sdk_bundle_source_cli
|
||||
name: 'Bundle CLI into SDK (from source)'
|
||||
if: "${{ env.BUILD_SDK == 'true' && (steps.cli_source.outputs.mode == 'build_from_source') }}"
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |
|
||||
node scripts/bundle-cli.js
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
- &sdk_unit_tests
|
||||
name: 'Run Tests'
|
||||
if: "${{ env.BUILD_SDK == 'true' && (github.event.inputs.force_skip_tests != 'true') }}"
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |
|
||||
npm run test:ci
|
||||
@@ -290,18 +319,44 @@ jobs:
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Run SDK Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
- &sdk_integration_tests
|
||||
name: 'Run SDK Integration Tests'
|
||||
if: "${{ env.BUILD_SDK == 'true' && (github.event.inputs.force_skip_tests != 'true') }}"
|
||||
run: |
|
||||
export SANDBOX_FLAGS="${SANDBOX_FLAGS:-} --label org.qwen-code.ci.owner=${RELEASE_CONTAINER_OWNER}"
|
||||
trap 'docker ps -aq --filter "label=org.qwen-code.ci.owner=${RELEASE_CONTAINER_OWNER}" | xargs -r docker rm -f' EXIT
|
||||
npm run test:integration:sdk:sandbox:none
|
||||
npm run test:integration:sdk:sandbox:docker
|
||||
sandbox_image="qwen-sdk-release:${RELEASE_CONTAINER_OWNER}"
|
||||
QWEN_SANDBOX=docker npm run build:sandbox -- --no-prune -i "$sandbox_image"
|
||||
export QWEN_SANDBOX_IMAGE="$(docker image inspect --format '{{.Id}}' "$sandbox_image")"
|
||||
[[ "$QWEN_SANDBOX_IMAGE" == sha256:* ]]
|
||||
QWEN_SANDBOX=docker npx vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript
|
||||
env:
|
||||
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
|
||||
RELEASE_CONTAINER_OWNER: '${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Record bundled CLI version'
|
||||
- &sdk_restore_npm_cli
|
||||
name: 'Restore CLI into SDK (from npm)'
|
||||
if: "${{ env.BUILD_SDK == 'true' && (steps.cli_source.outputs.mode == 'npm_latest') }}"
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
env:
|
||||
CLI_PACKAGE_PATH: '${{ steps.cli_download.outputs.CLI_TMP_DIR }}/package'
|
||||
run: |
|
||||
node scripts/bundle-cli-from-npm.js
|
||||
|
||||
- &sdk_restore_source_cli
|
||||
name: 'Restore CLI into SDK (from source)'
|
||||
if: "${{ env.BUILD_SDK == 'true' && (steps.cli_source.outputs.mode == 'build_from_source') }}"
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |
|
||||
node scripts/bundle-cli.js
|
||||
|
||||
- &sdk_cli_metadata
|
||||
name: 'Record bundled CLI version'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
env:
|
||||
CLI_VERSION: "${{ steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION }}"
|
||||
run: |
|
||||
@@ -309,17 +364,160 @@ jobs:
|
||||
echo "${CLI_VERSION}" > packages/sdk-typescript/dist/BUNDLED_CLI_VERSION
|
||||
echo "Bundled CLI version: ${CLI_VERSION}"
|
||||
|
||||
- name: 'Publish @qwen-code/sdk'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
- &sdk_packaging_helper
|
||||
name: 'Fetch SDK packaging helper from workflow revision'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
env:
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
WORKFLOW_SHA: '${{ github.workflow_sha }}'
|
||||
run: |-
|
||||
npm publish --provenance --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
|
||||
set -euo pipefail
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/sdk-release-package.mjs?ref=${WORKFLOW_SHA}" --jq .content | base64 --decode > "${RUNNER_TEMP}/sdk-release-package.mjs"
|
||||
|
||||
- &sdk_pack
|
||||
name: 'Pack the tested SDK without lifecycle scripts'
|
||||
if: "${{ env.BUILD_SDK == 'true' }}"
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
CLI_VERSION: "${{ steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION }}"
|
||||
run: 'node "${RUNNER_TEMP}/sdk-release-package.mjs" pack "${RUNNER_TEMP}/sdk-release" "$RELEASE_VERSION" "$CLI_VERSION"'
|
||||
|
||||
- name: 'Upload the tested SDK package'
|
||||
id: 'artifact'
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'sdk-release-package'
|
||||
path: '${{ runner.temp }}/sdk-release/'
|
||||
if-no-files-found: 'error'
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
|
||||
release-sdk:
|
||||
runs-on: 'ubuntu-latest'
|
||||
needs: ['build-sdk']
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
BUILD_SDK: "${{ needs.build-sdk.result != 'success' }}"
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ needs.build-sdk.outputs.release_tag || steps.version.outputs.RELEASE_TAG }}'
|
||||
if: |-
|
||||
${{
|
||||
always() && !cancelled() &&
|
||||
(needs.build-sdk.result == 'success' || (needs.build-sdk.result == 'skipped' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED != 'true')) &&
|
||||
github.repository == 'QwenLM/qwen-code' &&
|
||||
(
|
||||
github.event_name != 'release' ||
|
||||
(
|
||||
vars.RELEASE_SDK_SYNC_PUBLISH != 'false' &&
|
||||
startsWith(github.event.release.tag_name, 'v') &&
|
||||
github.event.release.prerelease == false
|
||||
)
|
||||
)
|
||||
}}
|
||||
permissions:
|
||||
contents: 'write'
|
||||
packages: 'write'
|
||||
id-token: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
outputs:
|
||||
RELEASE_TAG: '${{ needs.build-sdk.outputs.release_tag || steps.version.outputs.RELEASE_TAG }}'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ needs.build-sdk.outputs.source_sha || github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
package-manager-cache: false
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
- name: 'Install npm 11'
|
||||
run: 'npm install --global [email protected]'
|
||||
|
||||
- *sdk_pin_source
|
||||
- *sdk_flags
|
||||
- *sdk_dependencies
|
||||
- *sdk_version
|
||||
- *sdk_set_version
|
||||
- *sdk_cli_source
|
||||
- *sdk_cli_version_npm
|
||||
- *sdk_download_cli
|
||||
- *sdk_build_cli
|
||||
- *sdk_git_user
|
||||
- *sdk_build
|
||||
- *sdk_bundle_npm_cli
|
||||
- *sdk_bundle_source_cli
|
||||
- *sdk_unit_tests
|
||||
- *sdk_integration_tests
|
||||
- *sdk_restore_npm_cli
|
||||
- *sdk_restore_source_cli
|
||||
- *sdk_cli_metadata
|
||||
- *sdk_packaging_helper
|
||||
- *sdk_pack
|
||||
|
||||
- name: 'Download the tested SDK package'
|
||||
if: "${{ needs.build-sdk.result == 'success' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
artifact-ids: '${{ needs.build-sdk.outputs.artifact_id }}'
|
||||
path: '${{ runner.temp }}/sdk-release/'
|
||||
merge-multiple: true
|
||||
|
||||
- name: 'Fetch SDK packaging helper from workflow revision'
|
||||
env:
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
WORKFLOW_SHA: '${{ github.workflow_sha }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/sdk-release-package.mjs?ref=${WORKFLOW_SHA}" --jq .content | base64 --decode > "${RUNNER_TEMP}/sdk-release-package.mjs"
|
||||
|
||||
- name: 'Validate and restore SDK release manifest'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ needs.build-sdk.outputs.release_version || steps.version.outputs.RELEASE_VERSION }}'
|
||||
CLI_VERSION: "${{ needs.build-sdk.outputs.cli_version || (steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION) }}"
|
||||
run: |-
|
||||
node "${RUNNER_TEMP}/sdk-release-package.mjs" validate "${RUNNER_TEMP}/sdk-release" "$RELEASE_VERSION" "$CLI_VERSION"
|
||||
tar -xOf "${RUNNER_TEMP}/sdk-release/sdk.tgz" package/package.json > packages/sdk-typescript/package.json
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 'Publish @qwen-code/sdk'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ needs.build-sdk.outputs.release_version || steps.version.outputs.RELEASE_VERSION }}'
|
||||
NPM_TAG: '${{ needs.build-sdk.outputs.npm_tag || steps.version.outputs.NPM_TAG }}'
|
||||
DRY_RUN: '${{ needs.build-sdk.outputs.is_dry_run || steps.vars.outputs.is_dry_run }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
tarball="${RUNNER_TEMP}/sdk-release/sdk.tgz"
|
||||
if [[ "$DRY_RUN" == 'true' ]]; then
|
||||
npm publish "$tarball" --ignore-scripts --provenance --access public --tag="$NPM_TAG" --dry-run
|
||||
exit 0
|
||||
fi
|
||||
local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
|
||||
if remote_integrity=$(npm view "@qwen-code/sdk@${RELEASE_VERSION}" dist.integrity --json 2>"${RUNNER_TEMP}/sdk-npm-error"); then
|
||||
[[ "$(jq -r . <<< "$remote_integrity")" == "$local_integrity" ]] || { echo '::error::SDK version already exists with different bytes'; exit 1; }
|
||||
echo 'Identical SDK package already published; continuing release finalization.'
|
||||
elif grep -q 'E404' "${RUNNER_TEMP}/sdk-npm-error"; then
|
||||
npm publish "$tarball" --ignore-scripts --provenance --access public --tag="$NPM_TAG"
|
||||
else
|
||||
cat "${RUNNER_TEMP}/sdk-npm-error" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Create and switch to a release branch'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
${{ (needs.build-sdk.outputs.is_dry_run || steps.vars.outputs.is_dry_run) == 'false' && (needs.build-sdk.outputs.is_nightly || steps.vars.outputs.is_nightly) == 'false' && (needs.build-sdk.outputs.is_preview || steps.vars.outputs.is_preview) == 'false' }}
|
||||
id: 'release_branch'
|
||||
env:
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
RELEASE_TAG: '${{ needs.build-sdk.outputs.release_tag || steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}"
|
||||
git switch -c "${BRANCH_NAME}"
|
||||
@@ -327,11 +525,11 @@ jobs:
|
||||
|
||||
- name: 'Commit and Push package version (stable only)'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
${{ (needs.build-sdk.outputs.is_dry_run || steps.vars.outputs.is_dry_run) == 'false' && (needs.build-sdk.outputs.is_nightly || steps.vars.outputs.is_nightly) == 'false' && (needs.build-sdk.outputs.is_preview || steps.vars.outputs.is_preview) == 'false' }}
|
||||
id: 'persist_source'
|
||||
env:
|
||||
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
RELEASE_TAG: '${{ needs.build-sdk.outputs.release_tag || steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
# Only persist version bumps after a successful publish.
|
||||
git add packages/sdk-typescript/package.json
|
||||
@@ -340,7 +538,13 @@ jobs:
|
||||
echo "::notice::No version changes in sdk-typescript; skipping release branch push and PR creation."
|
||||
echo "HAS_PERSISTED_SOURCE=false" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
|
||||
if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" > /dev/null; then
|
||||
git fetch origin "${BRANCH_NAME}"
|
||||
[[ "$(git write-tree)" == "$(git rev-parse 'FETCH_HEAD^{tree}')" ]] || { echo '::error::Existing SDK release branch has different content'; exit 1; }
|
||||
git reset --soft FETCH_HEAD
|
||||
else
|
||||
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
|
||||
fi
|
||||
echo "HAS_PERSISTED_SOURCE=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "Pushing release branch to remote..."
|
||||
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
|
||||
@@ -348,18 +552,18 @@ jobs:
|
||||
|
||||
- name: 'Create GitHub Release and Tag'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && (steps.vars.outputs.is_nightly == 'true' || steps.vars.outputs.is_preview == 'true' || steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true') }}
|
||||
${{ (needs.build-sdk.outputs.is_dry_run || steps.vars.outputs.is_dry_run) == 'false' && ((needs.build-sdk.outputs.is_nightly || steps.vars.outputs.is_nightly) == 'true' || (needs.build-sdk.outputs.is_preview || steps.vars.outputs.is_preview) == 'true' || steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true') }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
RELEASE_TAG: '${{ needs.build-sdk.outputs.release_tag || steps.version.outputs.RELEASE_TAG }}'
|
||||
PREVIOUS_RELEASE_TAG: '${{ needs.build-sdk.outputs.previous_release_tag || steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||
IS_NIGHTLY: '${{ needs.build-sdk.outputs.is_nightly || steps.vars.outputs.is_nightly }}'
|
||||
IS_PREVIEW: '${{ needs.build-sdk.outputs.is_preview || steps.vars.outputs.is_preview }}'
|
||||
HAS_PERSISTED_SOURCE: '${{ steps.persist_source.outputs.HAS_PERSISTED_SOURCE }}'
|
||||
REF: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
|
||||
CLI_VERSION: "${{ steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION }}"
|
||||
CLI_SOURCE_MODE: '${{ steps.cli_source.outputs.mode }}'
|
||||
REF: '${{ needs.build-sdk.outputs.source_sha || steps.source.outputs.sha }}'
|
||||
CLI_VERSION: "${{ needs.build-sdk.outputs.cli_version || (steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION) }}"
|
||||
CLI_SOURCE_MODE: '${{ needs.build-sdk.outputs.cli_source || steps.cli_source.outputs.mode }}'
|
||||
run: |-
|
||||
# For stable releases, use the release branch when it was pushed.
|
||||
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
|
||||
@@ -398,23 +602,28 @@ jobs:
|
||||
printf '%s\n' "${PREVIOUS_NOTES}" >> "${NOTES_FILE}"
|
||||
|
||||
# Create GitHub release
|
||||
gh release create "sdk-typescript-${RELEASE_TAG}" \
|
||||
if gh release view "sdk-typescript-${RELEASE_TAG}" > /dev/null 2>&1; then
|
||||
git fetch origin "refs/tags/sdk-typescript-${RELEASE_TAG}:refs/tags/sdk-typescript-${RELEASE_TAG}"
|
||||
[[ "$(git rev-parse "sdk-typescript-${RELEASE_TAG}^{commit}")" == "$(git rev-parse "${TARGET}^{commit}")" ]] || { echo '::error::SDK release tag targets different content'; exit 1; }
|
||||
else
|
||||
gh release create "sdk-typescript-${RELEASE_TAG}" \
|
||||
--target "${TARGET}" \
|
||||
--title "SDK TypeScript Release ${RELEASE_TAG}" \
|
||||
--notes-file "${NOTES_FILE}" \
|
||||
${PRERELEASE_FLAG}
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f "${NOTES_FILE}"
|
||||
|
||||
- name: 'Create PR to merge release branch into main'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' && steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true' }}
|
||||
${{ (needs.build-sdk.outputs.is_dry_run || steps.vars.outputs.is_dry_run) == 'false' && (needs.build-sdk.outputs.is_nightly || steps.vars.outputs.is_nightly) == 'false' && (needs.build-sdk.outputs.is_preview || steps.vars.outputs.is_preview) == 'false' && steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true' }}
|
||||
id: 'pr'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
RELEASE_TAG: '${{ needs.build-sdk.outputs.release_tag || steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
|
||||
@@ -436,11 +645,11 @@ jobs:
|
||||
|
||||
- name: 'Enable auto-merge for release PR'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' && steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true' }}
|
||||
${{ (needs.build-sdk.outputs.is_dry_run || steps.vars.outputs.is_dry_run) == 'false' && (needs.build-sdk.outputs.is_nightly || steps.vars.outputs.is_nightly) == 'false' && (needs.build-sdk.outputs.is_preview || steps.vars.outputs.is_preview) == 'false' && steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
RELEASE_TAG: '${{ needs.build-sdk.outputs.release_tag || steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
# Keep [skip ci] only on the squash commit that lands on main. The
|
||||
@@ -456,12 +665,18 @@ jobs:
|
||||
--auto \
|
||||
--subject "chore(release): sdk-typescript ${RELEASE_TAG} [skip ci]"
|
||||
|
||||
report-failure:
|
||||
needs: ['build-sdk', 'release-sdk']
|
||||
if: "${{ always() && github.repository == 'QwenLM/qwen-code' && inputs.dry_run != true && (needs.build-sdk.result == 'failure' || needs.release-sdk.result == 'failure') }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
${{ failure() && steps.vars.outputs.is_dry_run == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}"
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
RELEASE_TAG: "${{ needs.release-sdk.outputs.RELEASE_TAG || needs.build-sdk.outputs.release_tag || 'N/A' }}"
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
gh issue create \
|
||||
|
||||
@@ -37,7 +37,7 @@ concurrency:
|
||||
jobs:
|
||||
# First job: Determine version and run tests once
|
||||
prepare:
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
# The release-event (sync-with-CLI) path can be paused by setting the
|
||||
# repository variable RELEASE_VSCODE_SYNC_PUBLISH=false; manual
|
||||
# workflow_dispatch releases keep working either way.
|
||||
@@ -63,6 +63,8 @@ jobs:
|
||||
is_dry_run: '${{ steps.vars.outputs.is_dry_run }}'
|
||||
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
@@ -88,6 +90,7 @@ jobs:
|
||||
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
if: "${{ runner.environment != 'self-hosted' }}"
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -229,11 +232,19 @@ jobs:
|
||||
target: ''
|
||||
universal: true
|
||||
|
||||
runs-on: '${{ matrix.os }}'
|
||||
# Only the universal leg can leave the hosted pool: it packages with
|
||||
# --omit=optional, so it carries no native node-pty and does not depend on
|
||||
# the build host's architecture. The linux-x64 leg resolves native
|
||||
# dependencies for the host it builds on and stays hosted until that
|
||||
# selection is explicitly cross-target. The release-only runners are ARM64,
|
||||
# so the label carries no architecture component.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'' && matrix.universal) && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || matrix.os }}'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
@@ -241,6 +252,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
if: "${{ runner.environment != 'self-hosted' }}"
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -311,7 +323,7 @@ jobs:
|
||||
needs:
|
||||
- 'prepare'
|
||||
- 'build'
|
||||
runs-on: 'ubuntu-latest'
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ needs.prepare.outputs.release_tag }}'
|
||||
@@ -320,6 +332,8 @@ jobs:
|
||||
issues: 'write'
|
||||
|
||||
steps:
|
||||
- uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
|
||||
+264
-113
@@ -54,8 +54,12 @@ jobs:
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
contents: 'read'
|
||||
actions: 'read'
|
||||
outputs:
|
||||
release_sha: '${{ steps.source.outputs.release_sha }}'
|
||||
audio_prebuild_key: '${{ steps.audio_key.outputs.cache_key }}'
|
||||
audio_artifact_id: '${{ steps.audio_reuse.outputs.artifact_id }}'
|
||||
audio_run_id: '${{ steps.audio_reuse.outputs.run_id }}'
|
||||
release_tag: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
release_version: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
npm_tag: '${{ steps.version.outputs.NPM_TAG }}'
|
||||
@@ -68,89 +72,7 @@ jobs:
|
||||
- &restore_release_workspace
|
||||
name: 'Restore workspace ownership'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
# Reject symlinked or non-canonical paths before recursive changes.
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -L "$RWS" ]; then
|
||||
echo "::error::refusing to wipe: runner workspace is a symlink: ${RWS}"
|
||||
exit 1
|
||||
fi
|
||||
RWS_LEX="$(realpath -m -s -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
if [ "$RWS" != "$RWS_LEX" ]; then
|
||||
echo "::error::refusing to wipe: runner workspace resolves through a symlinked component: ${RWS_LEX} resolves to ${RWS}"
|
||||
exit 1
|
||||
fi
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
WS="${GITHUB_WORKSPACE:?}"
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
# Heal only a corrupt leaf whose canonical parent remains contained.
|
||||
if [ -L "$WS" ] || [ ! -d "$WS" ]; then
|
||||
HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; }
|
||||
case "$HEAL_PARENT" in
|
||||
"$RWS"|"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;;
|
||||
esac
|
||||
if [ -L "$WS" ]; then
|
||||
# Keep attacker-controlled link text out of workflow commands.
|
||||
heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '<unreadable>')"
|
||||
heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)"
|
||||
echo "::warning::healing workspace ${WS}: it was a symlink"
|
||||
printf 'heal: %s pointed at %s\n' "$WS" "$heal_target"
|
||||
else
|
||||
echo "::warning::healing workspace ${WS}: it was not a directory"
|
||||
fi
|
||||
# Remove the raw link path; never resolve its target.
|
||||
rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; }
|
||||
mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; }
|
||||
fi
|
||||
WS_LEX="$(realpath -m -s -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; }
|
||||
WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; }
|
||||
if [ "$WS" != "$WS_LEX" ]; then
|
||||
echo "::error::refusing to wipe: workspace resolves through a symlinked component: ${WS_LEX} resolves to ${WS}"
|
||||
exit 1
|
||||
fi
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
case "$WS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;;
|
||||
esac
|
||||
# Deny known roots, then allow only runner-workspace descendants.
|
||||
case "$WS" in
|
||||
/|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;;
|
||||
esac
|
||||
case "$WS" in
|
||||
"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;;
|
||||
esac
|
||||
# Path geometry is validated before ownership changes.
|
||||
RUNNER_UID="$(id -u)"
|
||||
RUNNER_GID="$(id -g)"
|
||||
if [ "$RUNNER_UID" != "0" ]; then
|
||||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||||
fi
|
||||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||||
find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
# Isolate Git, npm, Docker, and gh state inherited across pool jobs.
|
||||
release_state="$(mktemp -d "${RUNNER_TEMP:?}/release-state.XXXXXX")" || exit 1
|
||||
: > "${release_state}/gitconfig" || exit 1
|
||||
: > "${release_state}/npmrc" || exit 1
|
||||
mkdir "${release_state}/docker" || exit 1
|
||||
mkdir "${release_state}/gh" || exit 1
|
||||
{
|
||||
echo 'GIT_CONFIG_COUNT=0'
|
||||
echo 'GIT_CONFIG_NOSYSTEM=1'
|
||||
echo 'GIT_CONFIG_PARAMETERS='
|
||||
echo "GIT_CONFIG_GLOBAL=${release_state}/gitconfig"
|
||||
echo "NPM_CONFIG_USERCONFIG=${release_state}/npmrc"
|
||||
echo "DOCKER_CONFIG=${release_state}/docker"
|
||||
echo "GH_CONFIG_DIR=${release_state}/gh"
|
||||
} >> "${GITHUB_ENV:?}"
|
||||
uses: '$/.github/actions/prepare-release-runner'
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
@@ -167,6 +89,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |-
|
||||
/.github/scripts
|
||||
/.github/workflows/audio-capture-prebuilds.yml
|
||||
/scripts/assert-release-version.mjs
|
||||
/scripts/check-release-tarball-exec-bits.mjs
|
||||
/scripts/release-packages.mjs
|
||||
@@ -228,6 +151,59 @@ jobs:
|
||||
|
||||
- *checkout_release_workflow_scripts
|
||||
|
||||
- name: 'Fingerprint audio build inputs'
|
||||
id: 'audio_key'
|
||||
continue-on-error: true
|
||||
env:
|
||||
RELEASE_SHA: '${{ steps.source.outputs.release_sha }}'
|
||||
run: |-
|
||||
directory=$(mktemp -d "${RUNNER_TEMP}/audio-reuse.XXXXXX")
|
||||
echo "directory=$directory" >> "$GITHUB_OUTPUT"
|
||||
node .release-workflow/.github/scripts/audio-prebuilds.mjs key "$RELEASE_SHA" .release-workflow >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Find reusable audio prebuilds'
|
||||
id: 'audio_candidate'
|
||||
continue-on-error: true
|
||||
uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0
|
||||
env:
|
||||
AUDIO_KEY: '${{ steps.audio_key.outputs.cache_key }}'
|
||||
with:
|
||||
script: |-
|
||||
const { findAudioArtifact } = await import(process.env.GITHUB_WORKSPACE + '/.release-workflow/.github/scripts/audio-prebuilds.mjs');
|
||||
const artifact = await findAudioArtifact(github, context.repo, process.env.AUDIO_KEY, core.warning);
|
||||
for (const [key, value] of Object.entries(artifact ?? {})) core.setOutput(key, value);
|
||||
|
||||
- name: 'Try reusable audio prebuilds'
|
||||
id: 'audio_download'
|
||||
if: "${{ steps.audio_candidate.outputs.artifact_id != '' }}"
|
||||
continue-on-error: true
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
artifact-ids: '${{ steps.audio_candidate.outputs.artifact_id }}'
|
||||
run-id: '${{ steps.audio_candidate.outputs.run_id }}'
|
||||
github-token: '${{ github.token }}'
|
||||
merge-multiple: true
|
||||
path: '${{ steps.audio_key.outputs.directory }}'
|
||||
|
||||
- name: 'Accept complete audio prebuilds'
|
||||
id: 'audio_reuse'
|
||||
if: "${{ steps.audio_download.outcome == 'success' }}"
|
||||
continue-on-error: true
|
||||
env:
|
||||
AUDIO_DIRECTORY: '${{ steps.audio_key.outputs.directory }}'
|
||||
AUDIO_ARTIFACT_ID: '${{ steps.audio_candidate.outputs.artifact_id }}'
|
||||
AUDIO_RUN_ID: '${{ steps.audio_candidate.outputs.run_id }}'
|
||||
run: |-
|
||||
node .release-workflow/.github/scripts/audio-prebuilds.mjs validate "$AUDIO_DIRECTORY"
|
||||
echo "artifact_id=$AUDIO_ARTIFACT_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "run_id=$AUDIO_RUN_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Remove trial audio download'
|
||||
if: "${{ always() && steps.audio_key.outputs.directory != '' }}"
|
||||
env:
|
||||
AUDIO_DIRECTORY: '${{ steps.audio_key.outputs.directory }}'
|
||||
run: 'rm -rf -- "$AUDIO_DIRECTORY"'
|
||||
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
env:
|
||||
@@ -487,10 +463,10 @@ jobs:
|
||||
# Cancelled runs stay cancelled; completed validation fails closed.
|
||||
if: |-
|
||||
${{ !cancelled() && needs.prepare.result == 'success' && github.event.inputs.force_skip_tests != 'true' }}
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: 'read'
|
||||
steps:
|
||||
- *restore_release_workspace
|
||||
|
||||
- name: 'Verify quality results'
|
||||
env:
|
||||
STATIC_RESULT: '${{ needs.quality_static.result }}'
|
||||
@@ -600,29 +576,184 @@ jobs:
|
||||
name: 'Audio Capture Prebuilds'
|
||||
needs: 'prepare'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
${{ github.repository == 'QwenLM/qwen-code' && needs.prepare.outputs.audio_artifact_id == '' }}
|
||||
uses: './.github/workflows/audio-capture-prebuilds.yml'
|
||||
with:
|
||||
ref: '${{ needs.prepare.outputs.release_sha }}'
|
||||
cache_key: '${{ needs.prepare.outputs.audio_prebuild_key }}'
|
||||
|
||||
build_release:
|
||||
name: 'Build Release Artifacts'
|
||||
# Reserve release-labelled runner slots before enabling this lane.
|
||||
runs-on: ['self-hosted', 'linux', 'ecs-qwen-release-host']
|
||||
timeout-minutes: 90
|
||||
needs:
|
||||
[
|
||||
'prepare',
|
||||
'quality',
|
||||
'integration_none',
|
||||
'integration_docker',
|
||||
'audio_capture_prebuilds',
|
||||
]
|
||||
if: |-
|
||||
${{
|
||||
always() &&
|
||||
github.repository == 'QwenLM/qwen-code' &&
|
||||
vars.ECS_RELEASE_HOST_RUNNER_ENABLED == 'true' &&
|
||||
needs.prepare.result == 'success' &&
|
||||
(
|
||||
needs.audio_capture_prebuilds.result == 'success' ||
|
||||
(needs.audio_capture_prebuilds.result == 'skipped' && needs.prepare.outputs.audio_artifact_id != '')
|
||||
) &&
|
||||
(
|
||||
github.event.inputs.force_skip_tests == 'true' ||
|
||||
(
|
||||
needs.quality.result == 'success' &&
|
||||
needs.integration_none.result == 'success' &&
|
||||
needs.integration_docker.result == 'success'
|
||||
)
|
||||
)
|
||||
}}
|
||||
permissions:
|
||||
contents: 'read'
|
||||
actions: 'read'
|
||||
outputs:
|
||||
release_commit: '${{ steps.handoff.outputs.release_commit }}'
|
||||
artifact_id: '${{ steps.upload.outputs.artifact-id }}'
|
||||
steps:
|
||||
- *restore_release_workspace
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ needs.prepare.outputs.release_sha }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- *release_disk_floor_gate
|
||||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||||
uses: './.github/actions/self-hosted-node'
|
||||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
run: |-
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only
|
||||
npm run postinstall
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- name: 'Prepare release branch'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh prepare-release-branch'
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- name: 'Commit release version locally'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh commit-release-branch'
|
||||
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- &verify_reused_audio
|
||||
name: 'Verify reusable audio source'
|
||||
if: "${{ needs.prepare.outputs.audio_artifact_id != '' }}"
|
||||
uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0
|
||||
env:
|
||||
RELEASE_SHA: '${{ needs.prepare.outputs.release_sha }}'
|
||||
AUDIO_ARTIFACT_ID: '${{ needs.prepare.outputs.audio_artifact_id }}'
|
||||
with:
|
||||
script: |-
|
||||
const { audioPrebuildKey, verifyAudioArtifact } = await import(process.env.GITHUB_WORKSPACE + '/.release-workflow/.github/scripts/audio-prebuilds.mjs');
|
||||
const key = audioPrebuildKey(process.env.GITHUB_WORKSPACE, process.env.RELEASE_SHA, process.env.GITHUB_WORKSPACE + '/.release-workflow');
|
||||
const { data: artifact } = await github.rest.actions.getArtifact({ ...context.repo, artifact_id: Number(process.env.AUDIO_ARTIFACT_ID) });
|
||||
await verifyAudioArtifact(github, context.repo, artifact, key);
|
||||
|
||||
- &download_reused_audio
|
||||
name: 'Download reusable audio prebuilds'
|
||||
if: "${{ needs.prepare.outputs.audio_artifact_id != '' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
artifact-ids: '${{ needs.prepare.outputs.audio_artifact_id }}'
|
||||
run-id: '${{ needs.prepare.outputs.audio_run_id }}'
|
||||
github-token: '${{ github.token }}'
|
||||
merge-multiple: true
|
||||
path: 'packages/audio-capture/prebuilds'
|
||||
|
||||
- &download_fresh_audio
|
||||
name: 'Download audio capture prebuilds'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' && needs.prepare.outputs.audio_artifact_id == '' }}
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
name: 'audio-capture-prebuilds'
|
||||
path: 'packages/audio-capture/prebuilds'
|
||||
|
||||
- &validate_audio
|
||||
name: 'Validate complete audio prebuilds'
|
||||
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
|
||||
run: 'node .release-workflow/.github/scripts/audio-prebuilds.mjs validate packages/audio-capture/prebuilds'
|
||||
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- name: 'Build Bundle and Prepare Package'
|
||||
env:
|
||||
QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1'
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh build-package'
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- name: 'Build Standalone Archives'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
|
||||
QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1'
|
||||
QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD: '1'
|
||||
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh build-archives'
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- name: 'Verify Standalone Archives'
|
||||
env:
|
||||
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh verify-archives'
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- name: 'Pack release handoff'
|
||||
id: 'handoff'
|
||||
env:
|
||||
RELEASE_SHA: '${{ needs.prepare.outputs.release_sha }}'
|
||||
BRANCH_NAME: "${{ format('release/{0}', needs.prepare.outputs.release_tag) }}"
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh pack-release'
|
||||
- name: 'Upload release handoff'
|
||||
id: 'upload'
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'release-publish-build'
|
||||
path: '${{ runner.temp }}/release-publish-build/'
|
||||
if-no-files-found: 'error'
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: 'Publish Release'
|
||||
# Keep release credentials off every machine that runs PR code. The
|
||||
# ecs-qwen-hk4-host pool is dual-labelled onto PR CI instances, so publish
|
||||
# only rides ecs-qwen-release-host: a dedicated, release-only machine set
|
||||
# opted into via ECS_RELEASE_HOST_RUNNER_ENABLED once ops provisions it.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
# npm trusted publishing and provenance require GitHub-hosted runners.
|
||||
runs-on: 'ubuntu-latest'
|
||||
needs:
|
||||
- 'prepare'
|
||||
- 'quality'
|
||||
- 'integration_none'
|
||||
- 'integration_docker'
|
||||
- 'audio_capture_prebuilds'
|
||||
- 'build_release'
|
||||
if: |-
|
||||
${{
|
||||
always() &&
|
||||
needs.prepare.result == 'success' &&
|
||||
(
|
||||
needs.build_release.result == 'success' ||
|
||||
(needs.build_release.result == 'skipped' && (github.repository != 'QwenLM/qwen-code' || vars.ECS_RELEASE_HOST_RUNNER_ENABLED != 'true'))
|
||||
) &&
|
||||
(
|
||||
github.repository != 'QwenLM/qwen-code' ||
|
||||
needs.audio_capture_prebuilds.result == 'success'
|
||||
needs.audio_capture_prebuilds.result == 'success' ||
|
||||
(needs.audio_capture_prebuilds.result == 'skipped' && needs.prepare.outputs.audio_artifact_id != '')
|
||||
) &&
|
||||
(
|
||||
github.event.inputs.force_skip_tests == 'true' ||
|
||||
@@ -645,26 +776,40 @@ jobs:
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.prepare.outputs.release_tag }}'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
actions: 'read'
|
||||
issues: 'write'
|
||||
packages: 'write'
|
||||
id-token: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
steps:
|
||||
- *restore_release_workspace
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
# Read-only fetch; the bot PAT is only exported to the write steps.
|
||||
# Read-only fetch with the job token; the bot PAT is only exported
|
||||
# to the write steps.
|
||||
token: '${{ github.token }}'
|
||||
ref: '${{ needs.prepare.outputs.release_sha }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- *release_disk_floor_gate
|
||||
- *checkout_release_workflow_scripts
|
||||
- name: 'Download release handoff'
|
||||
if: "${{ needs.build_release.result == 'success' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
artifact-ids: '${{ needs.build_release.outputs.artifact_id }}'
|
||||
merge-multiple: true
|
||||
path: '${{ runner.temp }}/release-publish-build'
|
||||
- name: 'Restore release commit and outputs'
|
||||
if: "${{ needs.build_release.result == 'success' }}"
|
||||
env:
|
||||
RELEASE_SHA: '${{ needs.prepare.outputs.release_sha }}'
|
||||
RELEASE_COMMIT: '${{ needs.build_release.outputs.release_commit }}'
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh restore-release'
|
||||
|
||||
- name: 'Setup Node.js (hosted)'
|
||||
if: "${{ runner.environment != 'self-hosted' }}"
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -672,13 +817,6 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
# ECS uses its pre-installed Node because nodejs.org may be unreachable.
|
||||
# npm OIDC trusted publishing needs no registry-url mapping: the default
|
||||
# registry is registry.npmjs.org.
|
||||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
uses: './.github/actions/self-hosted-node'
|
||||
|
||||
- name: 'Install npm 11'
|
||||
run: 'npm install --global [email protected]'
|
||||
|
||||
@@ -693,6 +831,7 @@ jobs:
|
||||
- *checkout_release_workflow_scripts
|
||||
|
||||
- name: 'Prepare release branch'
|
||||
if: "${{ needs.build_release.result != 'success' }}"
|
||||
id: 'release_branch'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
@@ -706,25 +845,26 @@ jobs:
|
||||
id: 'push_release_branch'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ github.token }}'
|
||||
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
BRANCH_NAME: "${{ format('release/{0}', needs.prepare.outputs.release_tag) }}"
|
||||
IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}'
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
|
||||
CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}'
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh push-release-branch'
|
||||
|
||||
- name: 'Download audio capture prebuilds'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
name: 'audio-capture-prebuilds'
|
||||
path: 'packages/audio-capture/prebuilds'
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
- *verify_reused_audio
|
||||
- *download_reused_audio
|
||||
- *download_fresh_audio
|
||||
|
||||
- *validate_audio
|
||||
|
||||
- *reset_release_workflow_scripts
|
||||
- *checkout_release_workflow_scripts
|
||||
|
||||
- name: 'Build Bundle and Prepare Package'
|
||||
if: "${{ needs.build_release.result != 'success' }}"
|
||||
env:
|
||||
QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}"
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh build-package'
|
||||
@@ -733,6 +873,7 @@ jobs:
|
||||
- *checkout_release_workflow_scripts
|
||||
|
||||
- name: 'Build Standalone Archives'
|
||||
if: "${{ needs.build_release.result != 'success' }}"
|
||||
env:
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
|
||||
QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}"
|
||||
@@ -778,7 +919,7 @@ jobs:
|
||||
env:
|
||||
# CI_BOT_PAT required: GITHUB_TOKEN events cannot trigger downstream release-event workflows.
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_BRANCH: "${{ format('release/{0}', needs.prepare.outputs.release_tag) }}"
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
PREVIOUS_RELEASE_TAG: '${{ needs.prepare.outputs.previous_release_tag }}'
|
||||
IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}'
|
||||
@@ -806,6 +947,8 @@ jobs:
|
||||
- 'quality'
|
||||
- 'integration_none'
|
||||
- 'integration_docker'
|
||||
- 'audio_capture_prebuilds'
|
||||
- 'build_release'
|
||||
- 'publish'
|
||||
if: |-
|
||||
${{
|
||||
@@ -816,6 +959,8 @@ jobs:
|
||||
) &&
|
||||
(
|
||||
needs.prepare.result == 'failure' ||
|
||||
needs.audio_capture_prebuilds.result == 'failure' ||
|
||||
needs.build_release.result == 'failure' ||
|
||||
needs.quality.result == 'failure' ||
|
||||
needs.integration_none.result == 'failure' ||
|
||||
needs.integration_docker.result == 'failure' ||
|
||||
@@ -856,6 +1001,8 @@ jobs:
|
||||
QUALITY_RESULT: '${{ needs.quality.result }}'
|
||||
INTEGRATION_NONE_RESULT: '${{ needs.integration_none.result }}'
|
||||
INTEGRATION_DOCKER_RESULT: '${{ needs.integration_docker.result }}'
|
||||
AUDIO_PREBUILDS_RESULT: '${{ needs.audio_capture_prebuilds.result }}'
|
||||
BUILD_RELEASE_RESULT: '${{ needs.build_release.result }}'
|
||||
PUBLISH_RESULT: '${{ needs.publish.result }}'
|
||||
run: '.release-workflow/.github/scripts/run-release-step.sh notify-failure'
|
||||
|
||||
@@ -888,6 +1035,8 @@ jobs:
|
||||
- 'quality'
|
||||
- 'integration_none'
|
||||
- 'integration_docker'
|
||||
- 'audio_capture_prebuilds'
|
||||
- 'build_release'
|
||||
- 'publish'
|
||||
if: |-
|
||||
${{
|
||||
@@ -898,6 +1047,8 @@ jobs:
|
||||
) &&
|
||||
needs.publish.result == 'success' &&
|
||||
needs.prepare.result != 'failure' &&
|
||||
needs.audio_capture_prebuilds.result != 'failure' &&
|
||||
needs.build_release.result != 'failure' &&
|
||||
needs.quality.result != 'failure' &&
|
||||
needs.integration_none.result != 'failure' &&
|
||||
needs.integration_docker.result != 'failure'
|
||||
|
||||
@@ -21,7 +21,7 @@ on:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json'
|
||||
- 'packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json'
|
||||
- 'scripts/check-contract-open-prs.js'
|
||||
- 'scripts/contract-version-utils.js'
|
||||
- '.github/workflows/sdk-java-contract-open-prs.yml'
|
||||
|
||||
@@ -17,9 +17,15 @@ on:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'packages/sdk-java/*/persistence/src/main/resources/db/migration/**'
|
||||
- 'packages/sdk-java/*/persistence/src/main/java/db/migration/**'
|
||||
- 'packages/sdk-java/*/persistence/src/main/resources/db/managed-schema/**'
|
||||
- 'packages/sdk-java/*/src/main/resources/db/migration/**'
|
||||
- 'packages/sdk-java/*/src/main/java/db/migration/**'
|
||||
- 'packages/sdk-java/*/src/main/resources/db/managed-schema/**'
|
||||
- 'packages/managed-agent-server/persistence/src/main/resources/db/migration/**'
|
||||
- 'packages/managed-agent-server/persistence/src/main/java/db/migration/**'
|
||||
- 'packages/managed-agent-server/persistence/src/main/resources/db/managed-schema/**'
|
||||
- 'packages/managed-agent-server/src/main/resources/db/migration/**'
|
||||
- 'packages/managed-agent-server/src/main/java/db/migration/**'
|
||||
- 'packages/managed-agent-server/src/main/resources/db/managed-schema/**'
|
||||
|
||||
+151
-110
@@ -27,9 +27,8 @@ env:
|
||||
MAVEN_SHA512: 'bcfe4fe305c962ace56ac7b5fc7a08b87d5abd8b7e89027ab251069faebee516b0ded8961445d6d91ec1985dfe30f8153268843c89aa392733d1a3ec956c9978'
|
||||
|
||||
concurrency:
|
||||
# Keyed by event and commit: a Managed Agent gate fast run and a full run
|
||||
# on the same pull request must not queue behind each other, where a newer
|
||||
# pending run would displace the older one.
|
||||
# Event and commit keys prevent a new pending fast/full run from replacing
|
||||
# an older one in the same queue.
|
||||
group: 'sdk-java-${{ github.workflow }}-${{ github.event_name }}-${{ inputs.ref || github.ref }}'
|
||||
# Release callers and daily runs must not displace each other's validation.
|
||||
cancel-in-progress: false
|
||||
@@ -199,7 +198,7 @@ jobs:
|
||||
cache: "${{ runner.environment == 'github-hosted' && 'maven' || '' }}"
|
||||
cache-dependency-path: |-
|
||||
packages/sdk-java/qwencode/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
settings-path: '${{ runner.temp }}/setup-java-m2'
|
||||
|
||||
# The Broker process tests spawn the Node fixture worker.
|
||||
@@ -294,7 +293,14 @@ jobs:
|
||||
# Maven runs would rewrite through into the host cache. Drop them
|
||||
# so the install writes fresh inodes.
|
||||
rm -rf -- "${RUNNER_TEMP}/m2-repo/com/alibaba/qwencode-sdk" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server"
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-parent" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-contracts" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-control-plane" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-runtime-broker" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-persistence" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-adapters" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-verification"
|
||||
# The suite resolves from the job-local copy above, so it does not
|
||||
# touch host-shared state and must not hold the per-host lock.
|
||||
# On 2026-10-22 run 39771949573's fast tier failed at its
|
||||
@@ -425,7 +431,7 @@ jobs:
|
||||
mkdir -p "${HOME}/.m2/repository" 9>&-
|
||||
cd "${RUNNER_TEMP}/m2-repo"
|
||||
write_back=0
|
||||
timeout 300 find . \( -path ./com/alibaba/qwencode-sdk -o -path ./com/alibaba/qwen-managed-agent-server -o -name '*.lastUpdated' -o -name 'resolver-status.properties' -o -name '_remote.repositories' -o -name '*.tmp' \) -prune -o -type f -links 1 -exec cp -ln --parents -t "${HOME}/.m2/repository" {} + 9>&- || write_back=$?
|
||||
timeout 300 find . \( -path ./com/alibaba/qwencode-sdk -o -path ./com/alibaba/qwen-managed-agent-parent -o -path ./com/alibaba/qwen-managed-agent-contracts -o -path ./com/alibaba/qwen-managed-agent-control-plane -o -path ./com/alibaba/qwen-managed-agent-runtime-broker -o -path ./com/alibaba/qwen-managed-agent-persistence -o -path ./com/alibaba/qwen-managed-agent-adapters -o -path ./com/alibaba/qwen-managed-agent-server -o -path ./com/alibaba/qwen-managed-agent-verification -o -name '*.lastUpdated' -o -name 'resolver-status.properties' -o -name '_remote.repositories' -o -name '*.tmp' \) -prune -o -type f -links 1 -exec cp -ln --parents -t "${HOME}/.m2/repository" {} + 9>&- || write_back=$?
|
||||
if [ "${write_back}" -eq 124 ]; then
|
||||
echo "::warning::Maven cache write-back stopped after 300 s; the gate result above is unaffected"
|
||||
elif [ "${write_back}" -ne 0 ]; then
|
||||
@@ -466,8 +472,8 @@ jobs:
|
||||
path: |
|
||||
packages/sdk-java/qwencode/target/surefire-reports/*.txt
|
||||
packages/sdk-java/qwencode/target/surefire-reports/*.dumpstream
|
||||
packages/managed-agent-server/target/surefire-reports/*.txt
|
||||
packages/managed-agent-server/target/surefire-reports/*.dumpstream
|
||||
packages/managed-agent-server/verification/target/surefire-reports/*.txt
|
||||
packages/managed-agent-server/verification/target/surefire-reports/*.dumpstream
|
||||
if-no-files-found: 'ignore'
|
||||
retention-days: 7
|
||||
|
||||
@@ -625,33 +631,14 @@ jobs:
|
||||
run: 'node scripts/check-flyway-migrations.js packages/managed-agent-server packages/sdk-java/qwencode'
|
||||
|
||||
mysql-integration:
|
||||
name: 'Runtime Broker and Managed Agent ${{ matrix.label }} / Java 21'
|
||||
name: 'Runtime Broker and Managed Agent MySQL / Java 21'
|
||||
if: "${{ inputs.tier != 'fast' }}"
|
||||
# Pool routing per the flyway job (#13245): hosted queue starvation fails
|
||||
# a zero-step job outright (#13623), the pool does not. The job is
|
||||
# pool-safe: the database is a per-leg container on a random host port,
|
||||
# both Maven runs use a job-local repository, and only the shared cache
|
||||
# write-back holds the test job's per-host sdk-java lock.
|
||||
# Both legs run the same non-Hosted *IT family, as before #14936 removed
|
||||
# the MariaDB service. The MariaDB leg is what keeps the published
|
||||
# MariaDB-only V12 repair (RTRIM, error 1901) and the schema catalog's
|
||||
# MariaDB branches falsifiable in CI (#14971).
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
strategy:
|
||||
# One engine's failure must not cancel the other leg's evidence.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- database: 'mysql'
|
||||
label: 'MySQL'
|
||||
image: 'mysql:8.4.6'
|
||||
client: 'mysql'
|
||||
engine_args: '--innodb-use-native-aio=0 --skip-log-bin'
|
||||
- database: 'mariadb'
|
||||
label: 'MariaDB'
|
||||
image: 'mariadb:10.11.18'
|
||||
client: 'mariadb'
|
||||
engine_args: '--innodb-use-native-aio=0'
|
||||
# On the pool this job takes the per-host sdk-java lock only for its
|
||||
# shared Maven cache write-back: both Maven runs resolve from a job-local
|
||||
# repository, so holding the lock across the whole integration suite
|
||||
@@ -663,10 +650,7 @@ jobs:
|
||||
# not as an expected duration.
|
||||
timeout-minutes: 310
|
||||
env:
|
||||
MYSQL_CONTAINER: 'qwen-code-sdk-java-${{ matrix.database }}-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
DATABASE_IMAGE: '${{ matrix.image }}'
|
||||
DATABASE_CLIENT: '${{ matrix.client }}'
|
||||
DATABASE_ENGINE_ARGS: '${{ matrix.engine_args }}'
|
||||
MYSQL_CONTAINER: 'qwen-code-sdk-java-mysql-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
steps:
|
||||
- name: 'Restore workspace ownership'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
@@ -798,7 +782,7 @@ jobs:
|
||||
cache: "${{ runner.environment == 'github-hosted' && 'maven' || '' }}"
|
||||
cache-dependency-path: |-
|
||||
packages/sdk-java/qwencode/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
settings-path: '${{ runner.temp }}/setup-java-m2'
|
||||
|
||||
- name: 'Point Maven at the pool mirror'
|
||||
@@ -847,7 +831,7 @@ jobs:
|
||||
# and fail to boot with `io_setup() failed with EAGAIN` (#14416).
|
||||
# Service containers cannot take a command, so the database starts here
|
||||
# instead, with native AIO disabled and its datadir on tmpfs. The MySQL
|
||||
# leg also binary-logs by default into that uncapped tmpfs, and this
|
||||
# server binary-logs by default into that uncapped tmpfs, and this
|
||||
# disposable container has no replica and nothing reads the log.
|
||||
# Each fork needs its own instance: lock tests assert server-wide
|
||||
# InnoDB deadlock counters, which separate schemas cannot isolate.
|
||||
@@ -855,39 +839,28 @@ jobs:
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
engine_args=()
|
||||
if [[ -n "${DATABASE_ENGINE_ARGS}" ]]; then
|
||||
read -r -a engine_args <<< "${DATABASE_ENGINE_ARGS}"
|
||||
fi
|
||||
for fork in 1 2; do
|
||||
container="${MYSQL_CONTAINER}-${fork}"
|
||||
docker run --detach --name "${container}" \
|
||||
--publish '127.0.0.1::3306' \
|
||||
--mount type=tmpfs,destination=/var/lib/mysql \
|
||||
--env MYSQL_ROOT_PASSWORD='runtime-broker' \
|
||||
"${DATABASE_IMAGE}" "${engine_args[@]}"
|
||||
# MariaDB ships healthcheck.sh; the MySQL image does not, so each
|
||||
# leg waits on the readiness signal its own image provides.
|
||||
if [[ "${DATABASE_CLIENT}" == 'mariadb' ]]; then
|
||||
health=(docker exec "${container}" healthcheck.sh --connect --innodb_initialized)
|
||||
else
|
||||
health=(docker exec "${container}" mysqladmin ping --host=127.0.0.1 --user=root --password=runtime-broker --silent)
|
||||
fi
|
||||
# A probe that is itself broken (image bump, renamed healthcheck.sh)
|
||||
mysql:8.4.6 --innodb-use-native-aio=0 --skip-log-bin
|
||||
# A probe that is itself broken (image bump, missing mysqladmin)
|
||||
# is otherwise indistinguishable from a server still starting.
|
||||
probe_out="${RUNNER_TEMP:-/tmp}/readiness-probe-${fork}.out"
|
||||
for attempt in {1..60}; do
|
||||
if "${health[@]}" >"${probe_out}" 2>&1; then
|
||||
if docker exec "${container}" mysqladmin ping --host=127.0.0.1 --user=root --password=runtime-broker --silent >"${probe_out}" 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! docker inspect --format '{{.State.Running}}' "${container}" | grep -qx true; then
|
||||
docker logs "${container}" || true
|
||||
echo "::error::${DATABASE_IMAGE} container stopped during startup; last ${DATABASE_CLIENT} readiness probe said: $(cat "${probe_out}")"
|
||||
echo "::error::MySQL container stopped during startup; last mysqladmin readiness probe said: $(cat "${probe_out}")"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${attempt}" == '60' ]]; then
|
||||
docker logs "${container}" || true
|
||||
echo "::error::${DATABASE_IMAGE} not ready after 60 probes; last ${DATABASE_CLIENT} readiness probe said: $(cat "${probe_out}")"
|
||||
echo "::error::MySQL not ready after 60 probes; last mysqladmin readiness probe said: $(cat "${probe_out}")"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
@@ -902,7 +875,7 @@ jobs:
|
||||
# gate, not the server packet limit, is what the test exercises.
|
||||
# Set both disposable containers before their first JDBC connections.
|
||||
for fork in 1 2; do
|
||||
docker exec "${MYSQL_CONTAINER}-${fork}" sh -c 'MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" '"${DATABASE_CLIENT}"' --user=root --execute="SET GLOBAL max_allowed_packet=134217728; SELECT @@GLOBAL.max_allowed_packet"'
|
||||
docker exec "${MYSQL_CONTAINER}-${fork}" sh -c 'MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" mysql --user=root --execute="SET GLOBAL max_allowed_packet=134217728; SELECT @@GLOBAL.max_allowed_packet"'
|
||||
done
|
||||
- name: 'Set up Node.js 22 (hosted)'
|
||||
if: "${{ runner.environment == 'github-hosted' }}"
|
||||
@@ -960,7 +933,14 @@ jobs:
|
||||
# Maven runs would rewrite through into the host cache. Drop them
|
||||
# so the install writes fresh inodes.
|
||||
rm -rf -- "${RUNNER_TEMP}/m2-repo/com/alibaba/qwencode-sdk" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server"
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-parent" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-contracts" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-control-plane" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-runtime-broker" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-persistence" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-adapters" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-verification"
|
||||
# Both Maven runs resolve from the job-local copy above, so they do
|
||||
# not touch host-shared state and must not hold the per-host lock:
|
||||
# a MySQL verify can run for hours under pool contention, and the
|
||||
@@ -1046,18 +1026,11 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Close the cache loop inside that lock: the one-way seed above
|
||||
# alone does not recapture the Server dependency tree after a pom
|
||||
# key rotation. The excludes name the two in-house fixed release
|
||||
# coordinates rather than the whole com/alibaba group, so
|
||||
# third-party artifacts under it (druid) still recapture into the
|
||||
# host-shared ~/.m2. Best-effort like the seed: the warning arms
|
||||
# keep a copy failure from reddening the gate after mvn has already
|
||||
# passed.
|
||||
# On a fresh ECS host nothing creates ~/.m2/repository anymore
|
||||
# (the hosted-only cache no longer restores it), so create it
|
||||
# before the write-back or every job re-resolves through the
|
||||
# egress proxy instead of bootstrapping the shared repository.
|
||||
# Under this lock, recapture dependencies after POM key changes.
|
||||
# Exclude only our fixed coordinates so Alibaba third-party artifacts
|
||||
# (e.g. druid) still enter shared ~/.m2. Copy failures only warn:
|
||||
# Maven already passed. Fresh ECS hosts need the repository created
|
||||
# first, or each job resolves through the proxy instead of seeding it.
|
||||
# Publish only this job's downloads: seeded files are hardlinks
|
||||
# (nlink >= 2), so the delta is nlink == 1. Taring the whole tree
|
||||
# took up to 61 minutes (run 39563394993). Each link is atomic and
|
||||
@@ -1072,7 +1045,7 @@ jobs:
|
||||
mkdir -p "${HOME}/.m2/repository" 9>&-
|
||||
cd "${RUNNER_TEMP}/m2-repo"
|
||||
write_back=0
|
||||
timeout 300 find . \( -path ./com/alibaba/qwencode-sdk -o -path ./com/alibaba/qwen-managed-agent-server -o -name '*.lastUpdated' -o -name 'resolver-status.properties' -o -name '_remote.repositories' -o -name '*.tmp' \) -prune -o -type f -links 1 -exec cp -ln --parents -t "${HOME}/.m2/repository" {} + 9>&- || write_back=$?
|
||||
timeout 300 find . \( -path ./com/alibaba/qwencode-sdk -o -path ./com/alibaba/qwen-managed-agent-parent -o -path ./com/alibaba/qwen-managed-agent-contracts -o -path ./com/alibaba/qwen-managed-agent-control-plane -o -path ./com/alibaba/qwen-managed-agent-runtime-broker -o -path ./com/alibaba/qwen-managed-agent-persistence -o -path ./com/alibaba/qwen-managed-agent-adapters -o -path ./com/alibaba/qwen-managed-agent-server -o -path ./com/alibaba/qwen-managed-agent-verification -o -name '*.lastUpdated' -o -name 'resolver-status.properties' -o -name '_remote.repositories' -o -name '*.tmp' \) -prune -o -type f -links 1 -exec cp -ln --parents -t "${HOME}/.m2/repository" {} + 9>&- || write_back=$?
|
||||
if [ "${write_back}" -eq 124 ]; then
|
||||
echo "::warning::Maven cache write-back stopped after 300 s; the gate result above is unaffected"
|
||||
elif [ "${write_back}" -ne 0 ]; then
|
||||
@@ -1084,16 +1057,23 @@ jobs:
|
||||
- name: 'Check that every non-Hosted integration test class ran'
|
||||
run: 'node scripts/check-failsafe-reports.js non-hosted packages/managed-agent-server'
|
||||
|
||||
- name: 'Verify ordinary and enabled embedding hosts'
|
||||
working-directory: 'packages/managed-agent-server'
|
||||
env:
|
||||
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml -Dmaven.repo.local=${{ runner.temp }}/m2-repo'
|
||||
run: 'bash ../../.github/scripts/verify-managed-agent-embedding.sh'
|
||||
|
||||
- name: 'Upload Maven test reports on failure'
|
||||
if: 'failure() || cancelled()'
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'sdk-java-test-reports-${{ matrix.database }}-integration-java-21'
|
||||
name: 'sdk-java-test-reports-mysql-integration-java-21'
|
||||
path: |
|
||||
packages/managed-agent-server/target/surefire-reports/*.txt
|
||||
packages/managed-agent-server/target/surefire-reports/*.dumpstream
|
||||
packages/managed-agent-server/target/failsafe-reports/*.txt
|
||||
packages/managed-agent-server/target/failsafe-reports/*.dumpstream
|
||||
packages/managed-agent-server/verification/src/test/embedding-host/target/surefire-reports/*
|
||||
packages/managed-agent-server/verification/target/surefire-reports/*.txt
|
||||
packages/managed-agent-server/verification/target/surefire-reports/*.dumpstream
|
||||
packages/managed-agent-server/verification/target/failsafe-reports/*.txt
|
||||
packages/managed-agent-server/verification/target/failsafe-reports/*.dumpstream
|
||||
if-no-files-found: 'ignore'
|
||||
retention-days: 7
|
||||
|
||||
@@ -1117,7 +1097,7 @@ jobs:
|
||||
name: 'Hosted process fault gates / MySQL 8.4 / Java 21'
|
||||
if: "${{ inputs.tier != 'fast' }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
# The step ceilings today (162 + 20 + 50 + 20 = 252 — the FG8 letters
|
||||
# The step ceilings today (201 + 20 + 50 + 20 = 291 — the FG8 letters
|
||||
# grew the shared failsafe fork to 3600 s, FG9 slice 3 to 3990 s, the
|
||||
# FG10b–f workflow relay letters to 4484 s, the FG12a MCP reply-loss
|
||||
# letter to 4499 s, the FG12c restart matrix to 4548 s, the FG13c Hook
|
||||
@@ -1191,10 +1171,16 @@ jobs:
|
||||
# pending CI measurement: fork 11444 s, Verify 201 minutes, and 12044 s
|
||||
# of fork plus wrap with the same 16 s left;
|
||||
# the Broker fault gates use 20,
|
||||
# and admission plus O4 keep per-fork ceilings) plus ~13 minutes for the
|
||||
# uncapped setup steps (Checkout.. cgroup delegation, the report
|
||||
# checks): 291 + 13. The failover arms run in hosted-failover-e2e.
|
||||
timeout-minutes: 304
|
||||
# and admission plus O4 keep per-fork ceilings) plus a 33-minute
|
||||
# allowance for the uncapped setup steps (Checkout.. cgroup delegation,
|
||||
# the report checks), which took under 10 minutes on every nightly from
|
||||
# 10-14 to 10-30: 291 + 33. A Verify that timed out used to skip
|
||||
# admission, leaving that path 50 minutes spare; admission now runs
|
||||
# after it, so that path too can fill all four capped ceilings. The
|
||||
# allowance sits above the ~13-minute floor so that a slow setup (the
|
||||
# CLI install makes up to three attempts) still leaves the O4 gates
|
||||
# time to run. The failover arms run in hosted-failover-e2e.
|
||||
timeout-minutes: 324
|
||||
services:
|
||||
mysql:
|
||||
image: 'mysql:8.4.6'
|
||||
@@ -1221,7 +1207,7 @@ jobs:
|
||||
cache: "${{ runner.environment == 'github-hosted' && 'maven' || '' }}"
|
||||
cache-dependency-path: |-
|
||||
packages/sdk-java/**/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
settings-path: '${{ runner.temp }}/setup-java-m2'
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
@@ -1314,13 +1300,14 @@ jobs:
|
||||
run: 'node scripts/check-failsafe-reports.js hosted packages/managed-agent-server'
|
||||
- name: 'Check Hosted latency measurements'
|
||||
run: |-
|
||||
test -s packages/managed-agent-server/target/hosted-latency-baseline.json
|
||||
test -s packages/managed-agent-server/verification/target/hosted-latency-baseline.json
|
||||
cd integration-tests
|
||||
npx vitest run cli/hosted-latency-baseline.test.ts
|
||||
- name: 'Run Runtime Broker fault gates'
|
||||
# The gates need only the CLI bundle and the installed SDK, so a red
|
||||
# Hosted verify above must not skip them: without this it did on 11
|
||||
# of the 14 nights to 10-27. A failed setup step still does.
|
||||
# Hosted verify above must not skip them: without this it did on 10
|
||||
# of the 14 nights to 10-27. The run cancelled on an 11th night still
|
||||
# skips them through !cancelled(), and so does a failed setup step.
|
||||
if: "${{ !cancelled() && steps.managed_dependencies.outcome == 'success' }}"
|
||||
# Outlive the 600 s surefire fork plus compilation and report teardown.
|
||||
timeout-minutes: 20
|
||||
@@ -1335,37 +1322,52 @@ jobs:
|
||||
with:
|
||||
name: 'hosted-harness-mysql-reports'
|
||||
path: |
|
||||
packages/managed-agent-server/target/failsafe-reports/
|
||||
packages/managed-agent-server/target/hosted-latency-baseline.json
|
||||
packages/managed-agent-server/target/fg7-cases/
|
||||
packages/managed-agent-server/target/fg8-cases/
|
||||
packages/managed-agent-server/target/fg9-cases/
|
||||
packages/managed-agent-server/target/fg10-cases/
|
||||
packages/managed-agent-server/target/fg11-cases/
|
||||
packages/managed-agent-server/target/fg12-cases/
|
||||
packages/managed-agent-server/target/fg13-cases/
|
||||
packages/managed-agent-server/target/l4-shell-lifecycle/
|
||||
packages/managed-agent-server/verification/target/failsafe-reports/
|
||||
packages/managed-agent-server/verification/target/hosted-latency-baseline.json
|
||||
packages/managed-agent-server/verification/target/fg7-cases/
|
||||
packages/managed-agent-server/verification/target/fg8-cases/
|
||||
packages/managed-agent-server/verification/target/fg9-cases/
|
||||
packages/managed-agent-server/verification/target/fg10-cases/
|
||||
packages/managed-agent-server/verification/target/fg11-cases/
|
||||
packages/managed-agent-server/verification/target/fg12-cases/
|
||||
packages/managed-agent-server/verification/target/fg13-cases/
|
||||
packages/managed-agent-server/verification/target/l4-shell-lifecycle/
|
||||
retention-days: 7
|
||||
|
||||
- name: 'Verify Hosted admission gates'
|
||||
id: 'hosted_admission'
|
||||
# Its clean would otherwise discard the hosted lane's failsafe
|
||||
# reports and the fg7-cases evidence the hosts upload above, so it
|
||||
# runs after the fixture. The hosted-harness-gates profile carries a
|
||||
# 2400 s failsafe fork budget for both admission witness classes
|
||||
# (@Timeout(1800) each); the same killed-after-timeout pattern as the
|
||||
# hosted-harness-mysql verify applies.
|
||||
# Like the fault gates it rebuilds the module and needs only what the
|
||||
# bundle step installs and builds, the installed SDK and the MySQL
|
||||
# service, so a red Hosted verify must not skip it: without this it
|
||||
# was skipped on all 13 nights from 10-14 to 10-30 that the verify
|
||||
# failed, three of them after the fault gates got this condition.
|
||||
# It boots on a fresh database, as O4 does: schema validation refuses
|
||||
# any table outside the managed set, and a Hosted IT killed before
|
||||
# its cleanup can leave its fg*_fire table behind.
|
||||
if: "${{ !cancelled() && steps.managed_dependencies.outcome == 'success' }}"
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
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'] }}"
|
||||
run: >-
|
||||
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}/hosted_harness_test?allowPublicKeyRetrieval=true&useSSL=false"
|
||||
-Dmysql.user=root -Dmysql.password=hosted-fixture clean verify checkstyle:check
|
||||
MYSQL_PWD: 'hosted-fixture'
|
||||
run: |-
|
||||
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
|
||||
- name: 'Check that every Hosted admission gate class ran'
|
||||
# It reads only the admission step's reports, so it follows that
|
||||
# step rather than the job status a red Hosted verify has already set.
|
||||
if: "${{ !cancelled() && steps.hosted_admission.outcome == 'success' }}"
|
||||
run: 'node scripts/check-failsafe-reports.js hosted-gates packages/managed-agent-server'
|
||||
|
||||
- name: 'Verify O4 filesystem process and capacity gates'
|
||||
@@ -1395,7 +1397,7 @@ jobs:
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'o4-mysql-gate-reports'
|
||||
path: 'packages/managed-agent-server/target/failsafe-reports/'
|
||||
path: 'packages/managed-agent-server/verification/target/failsafe-reports/'
|
||||
retention-days: 7
|
||||
|
||||
hosted-failover-e2e:
|
||||
@@ -1406,9 +1408,9 @@ jobs:
|
||||
# that a failing integration test there no longer skips them: they need
|
||||
# only the bundled CLI, the server jar and a runner-private mysqld, not
|
||||
# its MySQL service or cgroup root. Each arm also runs when an earlier
|
||||
# one failed. Ceilings: 22 arms × 10 = 220 plus ~13 minutes for the
|
||||
# uncapped setup steps (Checkout.. Install MySQL binaries): 233 + 2.
|
||||
timeout-minutes: 235
|
||||
# one failed. Ceilings: 27 arms × 10 = 270 plus ~13 minutes for the
|
||||
# uncapped setup steps (Checkout.. Install MySQL binaries): 283 + 2.
|
||||
timeout-minutes: 285
|
||||
env:
|
||||
QWEN_E2E_REGISTER_STORAGE: '1'
|
||||
steps:
|
||||
@@ -1424,7 +1426,7 @@ jobs:
|
||||
cache: "${{ runner.environment == 'github-hosted' && 'maven' || '' }}"
|
||||
cache-dependency-path: |-
|
||||
packages/sdk-java/**/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
settings-path: '${{ runner.temp }}/setup-java-m2'
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
@@ -1547,6 +1549,21 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-after-result
|
||||
- name: 'Run finished-before-commit crash E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-finished-before-commit
|
||||
- name: 'Run sibling-approval crash E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-sibling-approval
|
||||
- name: 'Run after-earlier-tools crash E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-after-earlier-tools
|
||||
- name: 'Run approval decision reply-loss E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
@@ -1557,6 +1574,11 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-admission-reply-lost
|
||||
- name: 'Run takeover load reply-loss E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-load-reply-lost
|
||||
- name: 'Run committed terminal reply-loss E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
@@ -1567,6 +1589,11 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-terminal-write-failed
|
||||
- name: 'Run refused cleanup release E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
npm run test:e2e:managed-harness-restart-approval-cleanup-failed
|
||||
- name: 'Run Harness-restart continuation closeout E2E'
|
||||
if: "${{ !cancelled() && steps.failover_prerequisites.outcome == 'success' }}"
|
||||
timeout-minutes: 10
|
||||
@@ -1733,7 +1760,7 @@ jobs:
|
||||
cache: "${{ runner.environment == 'github-hosted' && 'maven' || '' }}"
|
||||
cache-dependency-path: |-
|
||||
packages/sdk-java/**/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
settings-path: '${{ runner.temp }}/setup-java-m2'
|
||||
- name: 'Point Maven at the pool mirror'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
@@ -1796,7 +1823,14 @@ jobs:
|
||||
# Maven runs would rewrite through into the host cache. Drop them
|
||||
# so the install writes fresh inodes.
|
||||
rm -rf -- "${RUNNER_TEMP}/m2-repo/com/alibaba/qwencode-sdk" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server"
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-parent" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-contracts" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-control-plane" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-runtime-broker" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-persistence" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-adapters" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-verification"
|
||||
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true -Dmaven.javadoc.skip=true install
|
||||
- name: 'Cache the RocketMQ distribution'
|
||||
uses: 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' # v6.1.0
|
||||
@@ -1839,9 +1873,9 @@ jobs:
|
||||
with:
|
||||
name: 'event-transport-broker-gate-reports'
|
||||
path: |-
|
||||
packages/managed-agent-server/target/failsafe-reports/
|
||||
packages/managed-agent-server/target/event-transport-latency*.json
|
||||
packages/managed-agent-server/target/event-transport-latency.jfr
|
||||
packages/managed-agent-server/verification/target/failsafe-reports/
|
||||
packages/managed-agent-server/verification/target/event-transport-latency*.json
|
||||
packages/managed-agent-server/verification/target/event-transport-latency.jfr
|
||||
${{ runner.temp }}/rocketmq/logs/
|
||||
${{ runner.temp }}/rocketmq/home/logs/
|
||||
retention-days: 7
|
||||
@@ -1993,7 +2027,7 @@ jobs:
|
||||
cache: "${{ runner.environment == 'github-hosted' && 'maven' || '' }}"
|
||||
cache-dependency-path: |-
|
||||
packages/sdk-java/**/pom.xml
|
||||
packages/managed-agent-server/pom.xml
|
||||
packages/managed-agent-server/**/pom.xml
|
||||
settings-path: '${{ runner.temp }}/setup-java-m2'
|
||||
- name: 'Point Maven at the pool mirror'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
@@ -2078,7 +2112,14 @@ jobs:
|
||||
# Maven runs would rewrite through into the host cache. Drop them
|
||||
# so the install writes fresh inodes.
|
||||
rm -rf -- "${RUNNER_TEMP}/m2-repo/com/alibaba/qwencode-sdk" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server"
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-parent" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-contracts" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-control-plane" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-runtime-broker" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-persistence" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-adapters" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-server" \
|
||||
"${RUNNER_TEMP}/m2-repo/com/alibaba/qwen-managed-agent-verification"
|
||||
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true -Dmaven.javadoc.skip=true install
|
||||
- name: 'Install libfaketime'
|
||||
# The clock-skew row moves one node's whole wall clock. A pool host
|
||||
@@ -2175,8 +2216,8 @@ jobs:
|
||||
with:
|
||||
name: 'event-transport-multi-node-gate-reports'
|
||||
path: |-
|
||||
packages/managed-agent-server/target/failsafe-reports/
|
||||
packages/managed-agent-server/target/mq3-multi-node/
|
||||
packages/managed-agent-server/verification/target/failsafe-reports/
|
||||
packages/managed-agent-server/verification/target/mq3-multi-node/
|
||||
${{ runner.temp }}/rocketmq/logs/
|
||||
${{ runner.temp }}/rocketmq/home/logs/
|
||||
retention-days: 7
|
||||
|
||||
@@ -30,7 +30,6 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
name: 'Check TUI impact'
|
||||
if: "${{ github.event_name != 'schedule' }}"
|
||||
# Door for the parity/noflicker lanes below: route it like them (#13245) so
|
||||
# a starved hosted queue cannot hold their gate — it sat 40+ minutes there
|
||||
# while the pool stood idle. Untrusted fork PRs stay hosted: for
|
||||
@@ -41,29 +40,120 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: 'read'
|
||||
outputs:
|
||||
required: '${{ steps.changes.outputs.result }}'
|
||||
component: '${{ steps.changes.outputs.component }}'
|
||||
render: '${{ steps.changes.outputs.render }}'
|
||||
input: '${{ steps.changes.outputs.input }}'
|
||||
recovery: '${{ steps.changes.outputs.recovery }}'
|
||||
integration: '${{ steps.changes.outputs.integration }}'
|
||||
full: '${{ steps.changes.outputs.full }}'
|
||||
reason: '${{ steps.changes.outputs.reason }}'
|
||||
steps:
|
||||
- uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0
|
||||
id: 'changes'
|
||||
with:
|
||||
script: |-
|
||||
if (context.eventName !== 'pull_request') return true;
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...context.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
// The files API caps results at 3,000. An incomplete diff must run.
|
||||
if (files.length !== context.payload.pull_request.changed_files || files.length >= 3000) return true;
|
||||
const unrelated = (path) =>
|
||||
path.startsWith('packages/managed-agent-server/') || path.startsWith('docs/');
|
||||
return files.some((file) =>
|
||||
!unrelated(file.filename) || (file.previous_filename && !unrelated(file.previous_filename)));
|
||||
const selected = { component: false, render: false, input: false, recovery: false, integration: false, full: false };
|
||||
const reasons = new Set();
|
||||
const all = () => Object.keys(selected).forEach((key) => { selected[key] = true; });
|
||||
const groups = {
|
||||
render: [
|
||||
/^packages\/cli\/src\/ui\/.*(status.?line|footer|header|viewport|transcript|messages|markdown|theme|colors|layout|textUtils|HistoryItemDisplay|useGitBranchName|hooksCommand|contexts\/SessionContext|model\/)/i,
|
||||
/^packages\/core\/src\/telemetry\/uiTelemetry/i,
|
||||
/^integration-tests\/(renderer-matrix|interactive\/(tui-migration|interactive-session-viewport|statusline-parity))/i,
|
||||
],
|
||||
input: [
|
||||
/^packages\/cli\/src\/(ui\/.*(input|keypress|key-map|keyMatchers|kitty|mouse|focus|editor|clipboard|paste|text-buffer|line-edit|vim|completion|atCommandProcessor|useShellHistory)|utils\/.*(clipboard|paste|editor))/i,
|
||||
/^packages\/core\/src\/(utils\/editor|services\/visionBridge)/i,
|
||||
/^integration-tests\/interactive\/tui-(input-ownership|editor-parity|clipboard-source)/i,
|
||||
],
|
||||
recovery: [
|
||||
/^packages\/cli\/src\/ui\/.*(session|resume|rewind|restore|checkpoint|history|item-projection|exit-|backgroundWork|useBranchCommand|commands\/(clear|quit))/i,
|
||||
/^packages\/core\/src\/(core\/session-recovery|services\/(session|chatRecording|fileHistory|chatCompression|visionBridge)|config\/storage|tools\/(edit|write-file)|utils\/(api-history-metadata|transcript-records|conversation-chain|jsonl-utils|sessionStorageUtils|atomicFileWrite|paths))/i,
|
||||
/^integration-tests\/interactive\/tui-(session-recovery|checkpoint-restore)/i,
|
||||
],
|
||||
integration: [
|
||||
/^packages\/cli\/src\/(dualOutput\/|ui\/.*(shell|client-tool-run|dialogs-confirm|extension|useReactToolScheduler|keyToAnsi)|services\/(interactive-mod|mod-session)|config\/extension|commands\/extensions)/i,
|
||||
/^packages\/core\/src\/(extension|confirmation-bus|permissions|tools\/|services\/(shellExecutionService|backgroundShellRegistry|shellContextEnv)|sandbox\/|.*runtime-shell|mods\/tool)/i,
|
||||
/^integration-tests\/interactive\/tui-(foreground-shell|extension-acquisition|dual-output)/i,
|
||||
],
|
||||
};
|
||||
const sharedTurn = /^packages\/cli\/src\/(ui\/(opentui\/(live-turn|live-session|event-adapter)|hooks\/(use-llm-stream|useReactToolScheduler))|services\/interactive-mod)|^packages\/core\/src\/core\/(client|turn|llm-chat|coreToolScheduler)/;
|
||||
const broad = [
|
||||
/(^|\/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig[^/]*\.json)$/,
|
||||
/^(\.npmrc|\.nvmrc|esbuild\.config\.js)$/,
|
||||
/^patches\//,
|
||||
/^\.github\/(actions\/|scripts\/|workflows\/tui-parity\.yml$)/,
|
||||
/^scripts\/(tui-parity\/|tests\/(e2e-workflow|tui-impact)\.test\.js$|build|prepare|copy_|generate-git|cli-entry)/,
|
||||
/^packages\/core\/src\/config\/config\./,
|
||||
/^packages\/cli\/(scripts\/|src\/(utils\/(tui-runtime|npm-runtime)\.|index\.|cli\.|llm\.|config\/(config|settings)\.|ui\/(App[./]|AppContainer[./]|startInteractiveUI\.|opentui\/(opentui-(runtime|host|app-shell|assets)|renderer-selection|start-opentui-ui))))/,
|
||||
/^integration-tests\/(test-helper|interactive\/interactive-session\.|vitest|installed\/)/,
|
||||
];
|
||||
try {
|
||||
if (context.eventName !== 'pull_request') {
|
||||
all();
|
||||
reasons.add('Scheduled or manual full acceptance');
|
||||
} else {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...context.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
// GitHub caps listFiles at 3,000; never turn missing API evidence into a green skip.
|
||||
if (!Number.isInteger(context.payload.pull_request.changed_files) || files.length !== context.payload.pull_request.changed_files || files.length >= 3000) {
|
||||
throw new Error('Incomplete PR file list');
|
||||
}
|
||||
if (files.some((file) => typeof file.filename !== 'string' || !file.filename || (file.status === 'renamed' && typeof file.previous_filename !== 'string'))) {
|
||||
throw new Error('Incomplete PR file paths');
|
||||
}
|
||||
const paths = new Set(files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean)));
|
||||
for (const path of paths) {
|
||||
// Outside the TUI build/test closure, plus web-shell: its only CLI
|
||||
// path is the web-templates transcript bundle no TUI suite renders,
|
||||
// and ci.yml owns its build + E2E health. The sdk-java, sdk-python
|
||||
// and zed-extension trees carry no workspace manifest, so the
|
||||
// manifest audit below cannot see them; cua-driver is reached only
|
||||
// through node-repl's runtime import, which --cli-only never builds.
|
||||
if (/^(docs\/|packages\/(managed-agent-server|cua-driver|web-shell|chrome-extension|mobile-mcp|qwen-live|desktop|live-host|mobile-shell|sdk-java|sdk-python|zed-extension)\/|packages\/vscode-ide-companion\/src\/|integrations\/(external-context-mem0|external-context|test-failure-explorer)\/)/.test(path)) {
|
||||
reasons.add('Outside the TUI build/test closure');
|
||||
continue;
|
||||
}
|
||||
if (broad.some((pattern) => pattern.test(path))) {
|
||||
all();
|
||||
reasons.add('Shared renderer, runtime, dependency or test/build infrastructure');
|
||||
continue;
|
||||
}
|
||||
let mapped = false;
|
||||
for (const [group, patterns] of Object.entries(groups)) {
|
||||
if (patterns.some((pattern) => pattern.test(path)) || sharedTurn.test(path)) {
|
||||
selected[group] = selected.component = true;
|
||||
reasons.add(`${group} mechanism`);
|
||||
mapped = true;
|
||||
}
|
||||
}
|
||||
if (!mapped && /^(packages\/|integrations\/|scripts\/|integration-tests\/)/.test(path)) {
|
||||
selected.component = true;
|
||||
reasons.add('Unmapped source or test change: component checks; maintainers should consider manual full acceptance');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
all();
|
||||
reasons.add('Impact detection failed: conservative full acceptance');
|
||||
core.setFailed('Cannot classify a complete PR diff; full acceptance requested');
|
||||
}
|
||||
if (!reasons.size) reasons.add('No TUI impact');
|
||||
const reason = [...reasons].join('; ');
|
||||
for (const [key, value] of Object.entries(selected)) core.setOutput(key, String(value));
|
||||
core.setOutput('reason', reason);
|
||||
await core.summary.addHeading('TUI acceptance selection').addTable([
|
||||
[{ data: 'Lane', header: true }, { data: 'Selected', header: true }],
|
||||
...Object.entries(selected).map(([key, value]) => [key, String(value)]),
|
||||
]).addRaw(reason).write();
|
||||
|
||||
parity:
|
||||
name: 'TUI parity snapshots (ink vs opentui)'
|
||||
needs: 'changes'
|
||||
if: "${{ !cancelled() && github.event_name != 'schedule' && (needs.changes.result != 'success' || needs.changes.outputs.required != 'false') }}"
|
||||
if: "${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.component != 'false') }}"
|
||||
# Same-repo PRs, write-access authors and dispatches run on the ECS pool
|
||||
# (#13245: both jobs sat 26+ minutes in the hosted queue); other fork PRs
|
||||
# stay hosted because these jobs build the PR's code. Kill-switch:
|
||||
@@ -130,10 +220,13 @@ jobs:
|
||||
- name: 'Build cli'
|
||||
run: 'npm run build -- --cli-only'
|
||||
- name: 'Component parity snapshots (ink vs opentui)'
|
||||
if: "${{ needs.changes.result != 'success' || needs.changes.outputs.full != 'false' || needs.changes.outputs.component == 'true' }}"
|
||||
run: 'bash scripts/tui-parity/component-parity.sh'
|
||||
- name: 'Bundle the product for runtime and renderer comparison'
|
||||
if: "${{ needs.changes.result != 'success' || needs.changes.outputs.full != 'false' || needs.changes.outputs.render == 'true' || needs.changes.outputs.input == 'true' || needs.changes.outputs.recovery == 'true' || needs.changes.outputs.integration == 'true' }}"
|
||||
run: 'npm run bundle'
|
||||
- name: 'Three-leg product acceptance (Node+Ink, Bun+Ink, Bun+OpenTUI)'
|
||||
if: "${{ needs.changes.result != 'success' || needs.changes.outputs.full != 'false' }}"
|
||||
env:
|
||||
QWEN_SANDBOX: 'false'
|
||||
QWEN_TUI_ACCEPTANCE_OUT: '${{ runner.temp }}/tui-migration-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
@@ -151,6 +244,43 @@ jobs:
|
||||
npx vitest run interactive/tui-migration.test.ts interactive/tui-input-ownership.test.ts interactive/tui-foreground-shell.test.ts interactive/tui-clipboard-source.test.ts interactive/tui-extension-acquisition.test.ts interactive/tui-session-recovery.test.ts interactive/tui-editor-parity.test.ts interactive/statusline-parity.test.ts interactive/tui-dual-output.test.ts --retry=0 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false || failed=1
|
||||
done
|
||||
exit "$failed"
|
||||
- name: 'Targeted product acceptance (Node+Ink, Bun+Ink, Bun+OpenTUI)'
|
||||
if: "${{ needs.changes.result == 'success' && needs.changes.outputs.full == 'false' && (needs.changes.outputs.render == 'true' || needs.changes.outputs.input == 'true' || needs.changes.outputs.recovery == 'true' || needs.changes.outputs.integration == 'true') }}"
|
||||
env:
|
||||
QWEN_SANDBOX: 'false'
|
||||
QWEN_TUI_ACCEPTANCE_OUT: '${{ runner.temp }}/tui-migration-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
TUI_RENDER: '${{ needs.changes.outputs.render }}'
|
||||
TUI_INPUT: '${{ needs.changes.outputs.input }}'
|
||||
TUI_RECOVERY: '${{ needs.changes.outputs.recovery }}'
|
||||
TUI_INTEGRATION: '${{ needs.changes.outputs.integration }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
QWEN_HOME="$(mktemp -d "${RUNNER_TEMP}/tui-migration-home.XXXXXX")"
|
||||
export QWEN_HOME
|
||||
trap 'rm -rf "$QWEN_HOME"' EXIT
|
||||
cd integration-tests
|
||||
tests=()
|
||||
if [ "$TUI_RENDER" = true ]; then
|
||||
npx vitest run renderer-matrix.test.ts interactive/interactive-session-viewport.test.ts --retry=0 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false
|
||||
tests+=(interactive/tui-migration.test.ts interactive/statusline-parity.test.ts)
|
||||
fi
|
||||
if [ "$TUI_INPUT" = true ]; then
|
||||
tests+=(interactive/tui-input-ownership.test.ts interactive/tui-editor-parity.test.ts interactive/tui-clipboard-source.test.ts)
|
||||
fi
|
||||
if [ "$TUI_RECOVERY" = true ]; then
|
||||
tests+=(interactive/tui-session-recovery.test.ts)
|
||||
fi
|
||||
if [ "$TUI_INTEGRATION" = true ]; then
|
||||
tests+=(interactive/tui-foreground-shell.test.ts interactive/tui-extension-acquisition.test.ts interactive/tui-dual-output.test.ts)
|
||||
fi
|
||||
[ "${#tests[@]}" -gt 0 ]
|
||||
failed=0
|
||||
for leg in node:ink bun:ink bun:opentui; do
|
||||
export QWEN_E2E_RUNTIME="${leg%%:*}"
|
||||
export QWEN_E2E_RENDERER="${leg#*:}"
|
||||
npx vitest run "${tests[@]}" --retry=0 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false || failed=1
|
||||
done
|
||||
exit "$failed"
|
||||
- name: 'Upload product acceptance evidence'
|
||||
if: '${{ always() }}'
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
@@ -165,7 +295,7 @@ jobs:
|
||||
noflicker:
|
||||
name: 'OpenTUI no-flicker gate'
|
||||
needs: 'changes'
|
||||
if: "${{ !cancelled() && github.event_name != 'schedule' && (needs.changes.result != 'success' || needs.changes.outputs.required != 'false') }}"
|
||||
if: "${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.full != 'false' || needs.changes.outputs.render == 'true' || needs.changes.outputs.recovery == 'true') }}"
|
||||
# With secrets.QWEN_API_KEY it drives a
|
||||
# real model conversation; without it (fork PRs never receive secrets)
|
||||
# accept-noflicker.sh falls back to the offline scripted-stream gate.
|
||||
@@ -216,14 +346,17 @@ jobs:
|
||||
- name: 'Build cli'
|
||||
run: 'npm run build -- --cli-only'
|
||||
- name: 'OpenTUI no-flicker gate (0 clears, balanced DEC2026)'
|
||||
if: "${{ needs.changes.result != 'success' || needs.changes.outputs.full != 'false' || needs.changes.outputs.render == 'true' }}"
|
||||
env:
|
||||
# The script's default report dir is a fixed /tmp path, which
|
||||
# concurrent runs on one ECS host would share.
|
||||
OUT: '${{ runner.temp }}/opentui-noflicker-out'
|
||||
run: 'bash scripts/tui-parity/accept-noflicker.sh'
|
||||
- name: 'Bundle the product for cold checkpoint recovery'
|
||||
if: "${{ needs.changes.result != 'success' || needs.changes.outputs.full != 'false' || needs.changes.outputs.recovery == 'true' }}"
|
||||
run: 'npm run bundle'
|
||||
- name: 'Cold checkpoint recovery (Node+Ink, Bun+Ink, Bun+OpenTUI)'
|
||||
if: "${{ needs.changes.result != 'success' || needs.changes.outputs.full != 'false' || needs.changes.outputs.recovery == 'true' }}"
|
||||
env:
|
||||
QWEN_SANDBOX: 'false'
|
||||
QWEN_TUI_ACCEPTANCE_OUT: '${{ runner.temp }}/tui-checkpoint-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'packages/sdk-java/*/pom.xml'
|
||||
- 'packages/managed-agent-server/pom.xml'
|
||||
- 'packages/managed-agent-server/**/pom.xml'
|
||||
- '.github/workflows/verify-maven-repo.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -107,7 +107,14 @@ jobs:
|
||||
repo="${RUNNER_TEMP:?}/verify-maven-repo"
|
||||
STRIP_ARTIFACTS=(
|
||||
qwencode-sdk
|
||||
qwen-managed-agent-parent
|
||||
qwen-managed-agent-contracts
|
||||
qwen-managed-agent-control-plane
|
||||
qwen-managed-agent-runtime-broker
|
||||
qwen-managed-agent-persistence
|
||||
qwen-managed-agent-adapters
|
||||
qwen-managed-agent-server
|
||||
qwen-managed-agent-verification
|
||||
acp-sdk
|
||||
)
|
||||
for artifact in "${STRIP_ARTIFACTS[@]}"; do
|
||||
@@ -118,4 +125,4 @@ jobs:
|
||||
uses: 'actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
|
||||
with:
|
||||
path: '${{ runner.temp }}/verify-maven-repo'
|
||||
key: "verify-maven-repo-${{ hashFiles('packages/sdk-java/*/pom.xml', 'packages/managed-agent-server/pom.xml') }}"
|
||||
key: "verify-maven-repo-${{ hashFiles('packages/sdk-java/*/pom.xml', 'packages/managed-agent-server/**/pom.xml') }}"
|
||||
@@ -15,6 +15,10 @@
|
||||
bower_components
|
||||
# Generated by scripts/generate-changelog.js — do not hand-format.
|
||||
CHANGELOG.md
|
||||
# Generated by scripts/generate-managed-agent-configuration-reference.mjs,
|
||||
# whose --check compares them byte for byte.
|
||||
docs/users/managed-agent-configuration-reference.md
|
||||
docs/users/managed-agent-configuration-reference.zh-CN.md
|
||||
eslint.config.js
|
||||
**/generated
|
||||
gha-creds-*.json
|
||||
@@ -23,6 +27,9 @@ junit.xml
|
||||
# so formatting and `pnpm install` fight over the file. .yamllint.yml
|
||||
# already ignores it for the same reason.
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Helm templates are validated through Helm rendering.
|
||||
deploy/kubernetes/chart/templates/
|
||||
Thumbs.db
|
||||
packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
packages/cli/src/services/insight/templates/insightTemplate.ts
|
||||
@@ -42,3 +49,4 @@ packages/core/src/skills/bundled/browser-use/runtime/
|
||||
# next sync. eslint.config.js already ignores packages/mobile-mcp/** for the
|
||||
# same reason; .prettierignore had cua-driver but not its sibling.
|
||||
packages/mobile-mcp/
|
||||
packages/managed-agent-server/**/target/
|
||||
@@ -0,0 +1,56 @@
|
||||
# WebShell footer agent counter (background subagents)
|
||||
|
||||
## Scenario
|
||||
|
||||
Open a Web Shell / Qwen Code Desktop session and start a conversation
|
||||
(the chat must be non-empty: in the shipped entry the environment panel
|
||||
replaces the footer task list while no background agent runs, so the
|
||||
pill stays empty until the first agent starts). Launch three background
|
||||
subagents in one turn (Agent tool with `run_in_background: true`). Watch
|
||||
the composer footer status-bar pill while they run, until all three
|
||||
finish.
|
||||
|
||||
## Checks
|
||||
|
||||
- Non-empty chat, no background agent: the pill is empty in the shipped
|
||||
entry — the environment panel owns the task list.
|
||||
- While all three agents are running, the pill shows `3 local agents`.
|
||||
- With one background shell command running alongside, the pill shows
|
||||
`1 shell, 3 local agents`.
|
||||
- If an agent is parked by a session restart or resume while the others
|
||||
run (an approval prompt does NOT park it — that agent keeps running),
|
||||
the pill labels the parked one separately: `2 local agents, 1 local
|
||||
agent paused`. The paused label is visible only while at least one
|
||||
running agent keeps the pill open in the shipped entry.
|
||||
- As each agent reaches a terminal state, the count decreases (3 → 2 →
|
||||
1 → cleared) without a separate "done" line for agents; once the last
|
||||
background agent finishes, the pill is suppressed again in the shipped
|
||||
entry.
|
||||
- A paused agent alone (no running agent) does not re-open the pill in
|
||||
the shipped entry: parked work stays visible in the environment
|
||||
panel's subagents section.
|
||||
- Foreground Agent calls never contribute to the count.
|
||||
- The pill opens the Tasks panel on click, as before.
|
||||
|
||||
## Evidence
|
||||
|
||||
The label logic is covered by unit tests
|
||||
(`packages/web-shell/client/components/StatusBar.test.tsx`, 12 cases
|
||||
including the regression cases for the pre-existing shell/done labels).
|
||||
The footer wiring is covered by App-level tests
|
||||
(`packages/web-shell/client/App.test.tsx`): the pill receives the task
|
||||
snapshot while a background agent runs, stays suppressed in the shipped
|
||||
entry without one, and a host-provided footer receives agent tasks.
|
||||
|
||||
```sh
|
||||
cd packages/web-shell
|
||||
pnpm exec vitest run --config vitest.config.ts components/StatusBar.test.tsx client/App.test.tsx
|
||||
```
|
||||
|
||||
A live Desktop observation of this countdown has not been recorded yet
|
||||
(the PR's Risk & Scope lists "a live Desktop screenshot of the footer
|
||||
pill" as not validated). The label logic and the App-level wiring are
|
||||
unit-tested, and the scenario above is reproducible as written.
|
||||
|
||||
A baseline dry-run against the global `qwen` CLI is not applicable: the
|
||||
pill is a Web Shell surface and the CLI TUI does not render it.
|
||||
+220
-39
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: verify-pr
|
||||
description: This skill should be used to run a sandboxed deep verification of a qwen-code PR — "/verify-pr <n>", "/verify-pr <n> --publish" (local round that also posts the report comment), "深度验证这个 PR", "验证这个 PR 并把报告发到评论", A/B load-bearing proof against the base build, mock-free harnesses with wire oracles, and targeted gates — producing tmp/pr<n>-verify-<ts>/report.md plus a machine-readable verdict. Designed for the token-free CI verify job; also usable locally.
|
||||
description: This skill should be used to run a sandboxed deep verification of a qwen-code PR — "/verify-pr <n>", "/verify-pr <n> --publish" (local round that also posts the report comment), "深度验证这个 PR", "验证这个 PR 并把报告发到评论", "有更新了,重新验证", "重新验证 PR <n>", "re-verify after update", A/B load-bearing proof against the base build, mock-free harnesses with wire oracles, and targeted gates — producing tmp/pr<n>-verify-<ts>/report.md plus a machine-readable verdict. Designed for the token-free CI verify job; also usable locally.
|
||||
argument-hint: '<pr-number> [--publish] [--watch] [--repo <owner>/<repo>] [--assets-repo <owner>/<repo>] [--assets-branch <branch>]'
|
||||
---
|
||||
|
||||
@@ -22,6 +22,13 @@ Two entry paths share the contract below.
|
||||
- **Maintainer round on a workstation** — `/verify-pr <n>`, starting with
|
||||
`node scripts/verify-pr-prepare.mjs <n> --repo <owner>/<repo>` (the
|
||||
preflight requires the repository explicitly; `origin` is not a fallback).
|
||||
It writes the round's coordinates (`repo`, `pr`, `baseRefOid`, `headRefOid`)
|
||||
to `context.json` and refuses a tree that is not a clean checkout of the PR
|
||||
head — when it passes, the current tree is the head side of the A/B, so stay
|
||||
on the PR branch and keep it clean. The gate attests the source only:
|
||||
`dist/` and `node_modules/` are gitignored, so reuse the install and build
|
||||
only after establishing they were produced for this head, and otherwise
|
||||
refresh them (the local install strategy below).
|
||||
This round executes untrusted PR code, so it needs an isolation boundary
|
||||
(see _Local invocation_).
|
||||
|
||||
@@ -140,8 +147,15 @@ trigger the sandboxed `@qwen-code /verify` lane instead.
|
||||
|
||||
Run `node scripts/verify-pr-prepare.mjs <n> --repo <owner>/<repo>` on the host
|
||||
before anything else: it fetches the metadata snapshot, resolves and verifies
|
||||
both OIDs against this clone, creates `tmp/pr<n>-verify-<ts>/`, and reports the
|
||||
isolation signals it can see. It does not gate on them — resolving metadata
|
||||
both OIDs against this clone, refuses a tree that is not a clean, fully
|
||||
materialised checkout of the PR head (a wrong HEAD names the `git checkout`
|
||||
to run; uncommitted changes name the entries and a stash form that clears
|
||||
them; index flags that hide tracked state — a sparse checkout's
|
||||
skip-worktree, assume-unchanged — are refused too; the script never stashes
|
||||
for you),
|
||||
creates `tmp/pr<n>-verify-<ts>/` with a `context.json` holding `repo`, `pr`,
|
||||
`baseRefOid` and `headRefOid`, and reports the isolation signals it can see.
|
||||
It does not gate on them — resolving metadata
|
||||
needs `gh`, which the boundary excludes, so this path always runs on the host.
|
||||
The boundary check belongs inside: run
|
||||
`node scripts/verify-pr-prepare.mjs --require-isolation` as the round's first
|
||||
@@ -243,20 +257,32 @@ calls a day per PR — and the round is the expensive part. Repo-wide watching i
|
||||
a different probe (`qwen review watch-repo`, event `pr_pushed`); `--watch` stays
|
||||
per-PR because a round is per-PR.
|
||||
|
||||
**Do not assume `HEAD^1`/`HEAD^2` locally.** Those hold only for a merge-ref
|
||||
checkout; on a plain PR-head checkout `HEAD^1` is just the head's parent and
|
||||
`HEAD^2` usually does not exist, so the A/B would silently compare the wrong
|
||||
base. Resolve `baseRefOid` and `headRefOid` explicitly from `gh pr view` and
|
||||
use those OIDs throughout; if either is not present locally, report
|
||||
`inconclusive` rather than substituting a parent.
|
||||
**Local coordinates come from `context.json`, never from `HEAD`.**
|
||||
`verify-pr-prepare.mjs` writes `repo`, `pr`, `baseRefOid` and `headRefOid`
|
||||
into the round's `tmp/pr<n>-verify-<ts>/context.json`, and only passes when
|
||||
the current tree is a clean checkout of `headRefOid` — so the current tree
|
||||
**is** the head side of the A/B: reuse the tree instead of checking out a
|
||||
second head tree. Reusing its install and build is a separate question:
|
||||
they are gitignored, so a clean checkout can still carry artifacts from an
|
||||
earlier commit — reuse them only after establishing they were built for
|
||||
this head, and otherwise follow the local install strategy below. Read
|
||||
both OIDs from that file throughout the round. `HEAD^1`/`HEAD^2` hold only
|
||||
on the CI merge-ref checkout; on a
|
||||
plain PR-head checkout `HEAD^1` is just the head's parent and `HEAD^2`
|
||||
usually does not exist, so deriving the base from them would silently
|
||||
compare the wrong commit. If an OID the round needs is not present locally,
|
||||
report `inconclusive` rather than substituting a parent.
|
||||
|
||||
**Local follow-up rounds: check the PR's comment history before scoping.**
|
||||
**Local follow-up rounds: check the prior round before scoping.**
|
||||
The CI follow-up trigger (`previous-report.md` next to the context file)
|
||||
does not exist locally. When you have `gh` access (a maintainer-driven run
|
||||
on their own machine), look for prior verification rounds first —
|
||||
does not exist locally. Look for prior verification rounds in the newest
|
||||
`tmp/pr<n>-verify-*/` artifact dir — a round that was never published
|
||||
exists only there — and, when you have `gh` access (a maintainer-driven
|
||||
run on their own machine), in
|
||||
`gh api repos/<owner>/<repo>/issues/<n>/comments`, plus any linked assets
|
||||
branches or artifact indexes those comments name. If a substantive earlier
|
||||
round exists, run the follow-up protocol exactly as the CI path requires:
|
||||
branches or artifact indexes those comments name; take the newest
|
||||
substantive round across both sources. If one exists, run the follow-up
|
||||
protocol exactly as the CI path requires:
|
||||
lead with a previous-finding status table and **re-measure every
|
||||
carried-forward finding at the new head, never quote the old verdict**. The
|
||||
trigger is per finding, not per scenario: a finding whose repro needs a
|
||||
@@ -301,6 +327,42 @@ treat the round as a production change.
|
||||
on correctly suffixed values (including the repo's own `2s` / `500ms`)
|
||||
and still bound a stale `HARNESS_TURN_DEADLINE=600000` as 6.9 days.
|
||||
|
||||
**Manual re-verification: "有更新了,重新验证" / "re-verify after update".**
|
||||
When the maintainer says a previously verified PR has moved, that phrase is
|
||||
a follow-up trigger, not a fresh round, and it needs no PR number restated:
|
||||
resolve the PR from the session's earlier round, or from the newest
|
||||
`tmp/pr<n>-verify-*/` when the session has none — ask when that is
|
||||
ambiguous rather than verify a guessed PR. Then, in order:
|
||||
|
||||
1. Find the previous verified head `P` from the newest substantive round
|
||||
across both sources: the local artifact dir's report verdict line, and
|
||||
the verification comments (an unpublished round exists only locally; a
|
||||
CI round published after it supersedes it). No prior round anywhere →
|
||||
run a fresh round and say so.
|
||||
2. Fetch the current head and state with
|
||||
`gh pr view <n> --repo <owner>/<repo> --json headRefOid,state`.
|
||||
A merged or closed PR gets that answer, not a round. Head `H == P` → no
|
||||
round either: report that the head has not moved since `P`. Comments,
|
||||
reviews and CI results that arrived since are data to fold into that
|
||||
answer, never a reason to re-verify — the same rule the watch gate
|
||||
applies.
|
||||
3. Register the round in the shared watch state before starting it. Read
|
||||
`.qwen/tmp/verify-pr-watch-<n>.json`: when it already records `H` or
|
||||
marks a round in flight, let that round answer instead of starting a
|
||||
duplicate. Otherwise write `H` as in-flight to that file **before** the
|
||||
round starts — the same write-before-start rule the watch prompt follows,
|
||||
so a watch fire during the manual round skips `H` — and record `H` as
|
||||
verified in the same file once the round completes.
|
||||
4. Run the follow-up protocol above: classify the `P..H` delta, lead with
|
||||
the previous-finding status table, re-measure every carried-forward
|
||||
finding at `H`.
|
||||
5. Inherit the previous round's coordinates (`--repo`, assets repo/branch)
|
||||
instead of asking again. Publishing stays a per-invocation request: the
|
||||
phrase alone does not post a comment.
|
||||
|
||||
To make this trigger automatic instead of typed, install `--watch` (above);
|
||||
the two share that head-dedup file.
|
||||
|
||||
**Maintainer-driven local publish (only on explicit request).** "Never
|
||||
post" yields when the maintainer running the local round explicitly asks
|
||||
for a PR comment; `--publish` in the invocation is that request, and needs
|
||||
@@ -328,12 +390,34 @@ cloned repository's settings file cannot choose where evidence is published.
|
||||
Upload blobs
|
||||
with `gh api -X PUT repos/.../contents/<path> --input -` carrying a
|
||||
JSON payload — `-f content="$(base64 …)"` blows past ARG_MAX around 100 KB.
|
||||
Keep the report.md contract in the comment body: verdict line first,
|
||||
collapsed `<details>` Chinese summary immediately after, images referenced
|
||||
by the same kebab-case names the files carry. The local path has no
|
||||
publisher enforcing the fail/mismatch rule, so run
|
||||
`node scripts/verify-pr-check.mjs <artifact dir>` and clear its errors before
|
||||
publishing, then state the verdict word from `verdict.txt` verbatim. A published
|
||||
Keep the report.md contract in the comment body: verdict line first — a
|
||||
plain paragraph, never a `#` heading, which renders the whole verdict as
|
||||
one giant H1 — collapsed `<details>` Chinese summary immediately after,
|
||||
images referenced by the same kebab-case names the files carry. End the
|
||||
comment with the attribution footer, the same shape `/review` posts:
|
||||
|
||||
```text
|
||||
_— <model> via Qwen Code /verify-pr (v<cli-version>; skill <skill-hash>)_
|
||||
```
|
||||
|
||||
Resolve the three values from the round's own runtime, never from memory:
|
||||
the model is `$QWEN_CODE_MODEL_IDENTITY` when non-empty, else
|
||||
`$QWEN_CODE_MODEL`; the CLI version is `qwen --version`; the skill hash is
|
||||
the first 12 characters of
|
||||
`git hash-object --no-filters .qwen/skills/verify-pr/SKILL.md` — the content
|
||||
hash of the instructions this round actually loaded, which needs no git
|
||||
history. When the model cannot be resolved, omit the footer rather than
|
||||
post an empty identity — and never claim the CI lane's `sandboxed
|
||||
verification` signature for a workstation round. Assemble the exact comment
|
||||
body — report content plus the resolved footer — into `comment.md` in the
|
||||
artifact directory and post that file: it is what the read-back below
|
||||
compares against, and it gives the checker a place to verify the footer
|
||||
actually landed. The local path has no publisher enforcing the fail/mismatch
|
||||
rule, so run `node scripts/verify-pr-check.mjs <artifact dir>` and clear its
|
||||
errors and its verdict-shape and footer warnings before publishing (a
|
||||
missing footer is legitimate only when the model could not be resolved),
|
||||
act on a gates-size warning by recording the suites it ran under `gates`,
|
||||
then state the verdict word from `verdict.txt` verbatim. A published
|
||||
filename is spent: raw.githubusercontent serves a URL from cache, so
|
||||
overwriting `02-matrix.png` leaves every earlier link showing the old image.
|
||||
Republish as `02-matrix-v2.png`, update the body and every comment that quotes
|
||||
@@ -356,10 +440,12 @@ Measured example: while a round ran, a bot review with 4 Criticals landed
|
||||
against the previous head 20 minutes before the post. It was not read, the
|
||||
report went out as ready to merge, every blocking finding in it reproduced
|
||||
on the current head, and the comment had to be revised in place to "Not
|
||||
ready yet". After posting, read the body back and compare it with the
|
||||
local file byte for byte, and PATCH in place on any difference: GitHub
|
||||
rewrites the six-character escape text for NUL and SOH (a backslash, `u`,
|
||||
then `0000` or `0001`) into a caret form, inside code spans and fences too.
|
||||
ready yet". After posting, read the body back and compare it with
|
||||
`comment.md` byte for byte — not with `report.md`, which carries no footer
|
||||
and would differ on every clean post — and PATCH in place on any difference:
|
||||
GitHub rewrites the six-character escape text for NUL and SOH (a backslash,
|
||||
`u`, then `0000` or `0001`) into a caret form, inside code spans and fences
|
||||
too.
|
||||
|
||||
**Run both halves of that guard through `scripts/verify-pr-guard.mjs`, because
|
||||
the hand-run version fails open.** `--pr <n> --repo <owner>/<repo> --snapshot
|
||||
@@ -447,13 +533,13 @@ Run the identical scenario against the PR build and a control build that
|
||||
differs only by the change under test; the verdict is the pair of counts.
|
||||
|
||||
- Base side: `git worktree add tmp/base-tree <base>` where `<base>` is
|
||||
`HEAD^1` **only on the CI merge-ref checkout**; in local mode it is the
|
||||
resolved `baseRefOid` from the metadata snapshot, because a plain PR-head
|
||||
checkout's `HEAD^1` is the previous PR commit and would attribute earlier
|
||||
commits of this PR to the change under test. (Keep scratch worktrees
|
||||
under `tmp/` and `git worktree remove --force` them once the A/B cells are
|
||||
captured — the workflow sweeps leftover `tmp/` worktrees as a backstop, but
|
||||
never rely on it), then rebuild **only the
|
||||
`HEAD^1` on the CI merge-ref checkout, else the `baseRefOid` from the
|
||||
round's `context.json` — never a plain PR-head checkout's `HEAD^1`, which
|
||||
is the previous PR commit and would attribute earlier commits of this PR
|
||||
to the change under test (the local-coordinates rule above). (Keep scratch
|
||||
worktrees under `tmp/` and `git worktree remove --force` them once the A/B
|
||||
cells are captured — the workflow sweeps leftover `tmp/` worktrees as a
|
||||
backstop, but never rely on it), then rebuild **only the
|
||||
affected workspace or file** — e.g. `npm run build -w packages/<ws>` inside
|
||||
the base tree wired to the already-installed root `node_modules`, or
|
||||
recompile the single changed module. A full base `pnpm install` rarely fits the
|
||||
@@ -482,7 +568,12 @@ differs only by the change under test; the verdict is the pair of counts.
|
||||
The control must differ by nothing else — name the exact commit/hunk it
|
||||
represents.
|
||||
- **Local runs start with no pre-built tree — pick the install strategy by
|
||||
verification level before spending the clock.** A dist-level harness needs
|
||||
verification level before spending the clock.** A tree left clean at the
|
||||
head by an earlier round is not pre-built _for this head_ until you can
|
||||
show its artifacts were produced for it; when you cannot, treat it as
|
||||
unbuilt and rebuild the affected workspace before measuring — a stale
|
||||
`dist/` under a clean head is the one staleness the prepare gate cannot
|
||||
see. A dist-level harness needs
|
||||
a full `npm ci` at head (the `prepare` build included; measured ~8 min
|
||||
warm-cache on a 12-core aarch64 box). A prototype/source-level harness
|
||||
whose bundler compiles the TS sources directly (e.g.
|
||||
@@ -493,7 +584,11 @@ differs only by the change under test; the verdict is the pair of counts.
|
||||
hardlinks are free, and the relative `@qwen-code/*` symlinks then resolve
|
||||
into the base tree, which the realpath assertion above can confirm. Do not
|
||||
symlink the directory itself — the internal links would resolve through it
|
||||
back into the head tree and both arms would run head code.
|
||||
back into the head tree and both arms would run head code. Hardlinks
|
||||
share inodes, and the base build can write under `node_modules` (its
|
||||
postinstall patch step), so a base arm that rebuilds needs real copies
|
||||
from the store instead
|
||||
(`pnpm install --offline --frozen-lockfile --config.package-import-method=copy`).
|
||||
- Report the cell table: environment per cell, observable oracle per cell
|
||||
(exit code, stderr line, wire request, rendered frame), and `X/Y` at head
|
||||
vs control. "5/9 flip from broken to fixed" is the shape to aim for.
|
||||
@@ -885,7 +980,8 @@ least one test that imports it.
|
||||
(`../escape` has two dots and a slash). Likewise a mode-000 fixture
|
||||
violates read and execute at once, so dropping either check survived 990
|
||||
unit tests.
|
||||
- Runner hygiene (time caps, PID-only kills, restoring the tree, syntax
|
||||
- Runner hygiene (time caps, PID-only kills, restoring the tree, a tree of
|
||||
its own apart from every live cell, the clean-`dist` launch gate, syntax
|
||||
checks that do not execute) is in **Rig hygiene (local rounds)** below.
|
||||
|
||||
**A test that can skip is coverage only where it executes.** A test gated on
|
||||
@@ -1177,6 +1273,41 @@ results in both directions.
|
||||
- Run scenarios that kill processes, revoke grants or wedge a shared
|
||||
resource alone on their arm; three runs that overlapped a concurrent
|
||||
revocation probe had to be set aside and repeated.
|
||||
- Give the mutation matrix a tree of its own — never the tree a live cell
|
||||
is measured on. Restoring the source after each row is not enough: a
|
||||
build inside a row propagates the mutant into `dist/`, and a daemon
|
||||
started mid-row loads it. Isolate every artifact a launch resolves, not
|
||||
just the checkout: `cp -al` the round's `node_modules` — root plus any
|
||||
per-package ones — into the matrix tree (never symlink the directory
|
||||
itself) and assert each `@qwen-code/*` link resolves inside it (a nested
|
||||
tree without its own `node_modules` walks up to the round's); a row that
|
||||
writes under `node_modules` needs real copies from the store
|
||||
(`pnpm install --offline --frozen-lockfile --config.package-import-method=copy`),
|
||||
because hardlinks share the round's inodes and pnpm's default import
|
||||
falls back to hardlinks where clones are unavailable. Build the sandbox
|
||||
image under a per-tree tag — a sanitized tree label, never a path
|
||||
(`QWEN_SANDBOX_IMAGE_TAG=<label>`; the build reads that variable, not
|
||||
`QWEN_SANDBOX_IMAGE`, and interpolates it into the tag verbatim) — and
|
||||
inject `QWEN_SANDBOX_IMAGE=ghcr.io/qwenlm/qwen-code:<label>` per launch
|
||||
of that tree's cells, never as a shell-wide export, which every later
|
||||
cell inherits; pass no `--sandbox-image` in those cells — the flag
|
||||
outranks the variable (the tag in `packages/cli/package.json` is one
|
||||
shared name any tree's build retags). Give Java rows their own
|
||||
`-Dmaven.repo.local` copied from the warmed repository. Before the
|
||||
matrix starts, record a sha256 manifest of each measurement arm's
|
||||
clean `dist/`, and gate every daemon or dist-level harness launch
|
||||
outside the row that produced it on a scripted comparison against that
|
||||
manifest — never against the matrix tree's live source, which mid-row
|
||||
is the mutant and agrees with its own build. Prove the gate live by
|
||||
requiring it to refuse a known-mutant build, not merely to report a
|
||||
fabricated mismatch. Never hand-patch `dist/` to repair a contaminated
|
||||
build; rebuild from source you have verified clean and re-record the
|
||||
manifest from that build — a hand-patch that restores only the visible
|
||||
sites leaves the rest of the build mutant. Measured example: a round
|
||||
interleaved its mutation matrix with 80 live daemon cells on one tree,
|
||||
then hand-patched the mutant it found in `dist/`; the patched build
|
||||
still carried the mutant expression, so every live cell and the whole
|
||||
matrix were withdrawn and the round shipped as `inconclusive`.
|
||||
- Stop processes only by the PIDs you recorded. After every batch, list
|
||||
orphaned (ppid 1) processes under the rig path and expect zero.
|
||||
- Restore the tree and tear the rig down before exiting. `process.exit()`
|
||||
@@ -1205,6 +1336,16 @@ vitest) and cite exact counts. Never claim a repo-wide gate you did not run;
|
||||
never re-run what the PR's own CI already covers unless your A/B needs the
|
||||
number from a known-clean state.
|
||||
|
||||
**Gate counts are not assertions.** Record each suite under `gates` in
|
||||
`assertions.json`, as `{"suite": "cli acp-session", "pass": 1292, "fail": 0}`,
|
||||
never in `pass`, `fail` or `total`. Those count the checks this round wrote.
|
||||
Folding a suite in turns them into a count of the repo's tests: a round
|
||||
published "7737 scripted assertions" while holding a few dozen checks of its
|
||||
own. A mutant run is likewise one assertion (killed, or survived as
|
||||
predicted), not its suite's test count. A gate red the PR causes still reaches
|
||||
the verdict as an assertion, green at base and red at head, with its
|
||||
attribution.
|
||||
|
||||
**Prove the gate is live before citing it as evidence.** A linter that exits
|
||||
0 because it matched no files looks exactly like a linter that passed: plant
|
||||
a violation it must catch (an unused variable, a formatting break), confirm
|
||||
@@ -1432,7 +1573,15 @@ or contract version too.
|
||||
Create `tmp/pr<n>-verify-<YYYYMMDD-HHMMSS>/` (the `-verify-` infix is what the
|
||||
workflow globs). It must contain:
|
||||
|
||||
- `report.md` — the deliverable (structure below).
|
||||
- `report.md` — the deliverable (structure below). No attribution footer in
|
||||
it: both publish paths append the footer at comment time — the workflow
|
||||
from its own run metadata, a local round per the publish rule — so a
|
||||
footer written into the report would appear twice.
|
||||
- `comment.md` — local rounds only: the exact comment body as posted, the
|
||||
report content plus the resolved attribution footer. The workflow
|
||||
assembles its own body and appends its own footer, so it neither writes
|
||||
nor needs this file; a local round posts it, and the checker verifies the
|
||||
footer on it because `report.md` deliberately has none.
|
||||
- `verdict.txt` — exactly one word: `merge-ready` | `findings` | `blocked` |
|
||||
`inconclusive`. Anything else is discarded by the workflow.
|
||||
- `assertions.json` — `{"pass": <int>, "fail": <int>, "total": <int>}`,
|
||||
@@ -1440,7 +1589,9 @@ workflow globs). It must contain:
|
||||
nonzero add `failures`, one `{id, attribution}` entry per failed assertion,
|
||||
with `attribution` one of `pr`, `harness` or `environment` — the verdict is
|
||||
read from that ledger, so a red the PR did not cause cannot decide it (see
|
||||
**Hard rules**).
|
||||
**Hard rules**). Existing test suites run as targeted gates go in `gates`,
|
||||
one `{suite, pass, fail}` entry per suite (`skipped` optional), never in the
|
||||
three counts (see _Targeted gates_).
|
||||
- `mutations.json` — the mutation matrix as data, whenever the round ran one:
|
||||
`rows[]`, and, when the report claims per-commit coverage,
|
||||
`coverageClaim: "per-commit"` plus `productionCommits` from
|
||||
@@ -1506,10 +1657,37 @@ reviewer's attention; `blocked` = the central claim failed its A/B or a
|
||||
regression reproduced; `inconclusive` = budget or environment prevented the
|
||||
central claim from being tested — say why.
|
||||
|
||||
For `findings`, the published headline follows the counts, not the word
|
||||
alone: `findings` with `fail: 0` is published as passed with findings
|
||||
(`blocked` is published as not passed at any count). A defect you reproduced
|
||||
at head is therefore a failed assertion attributed to `pr`, never an
|
||||
expected-red pass — that rule covers control cells, which exist to fail, not
|
||||
head behaviour the PR got wrong. Measured example: a round reproduced a
|
||||
regression, asserted that the head showed it, and published `blocked` over
|
||||
4382 passes and 0 failures. The CI publisher runs `verify-pr-check.mjs` before
|
||||
it trusts any verdict, so run it on the artifact dir before you finish, in
|
||||
either path. The publisher reads only the checker's last line and never
|
||||
echoes warnings, so a gates-size warning reaches nobody but you — record
|
||||
the suites under `gates` before publishing.
|
||||
|
||||
### report.md structure
|
||||
|
||||
1. **Verdict line first**, with assertion totals and the verified head OID
|
||||
(`git rev-parse HEAD^2` — not the snapshot's, which may have drifted).
|
||||
(`git rev-parse HEAD^2` in the lane — not the metadata snapshot's, which
|
||||
may have drifted; the `headRefOid` from `context.json` locally, which
|
||||
prepare pinned to the clean tree the round verified). The verdict line is
|
||||
a **plain paragraph, never a heading**: a `#` prefix makes GitHub render
|
||||
the entire paragraph as one giant H1. A round title may lead the same
|
||||
line as plain text, but the line must not start with `#`:
|
||||
|
||||
```text
|
||||
PR <n> round <r> verification — **Verdict: <word>** — <pass>/<total> scripted assertions passed, <fail> failed. Verified head: `<40-hex head OID>` …
|
||||
```
|
||||
|
||||
Measured example: three rounds of PR #15984 shipped the whole verdict
|
||||
paragraph as one H1 because the title and the verdict shared a single
|
||||
`#` line — the comment opened as a wall of giant bold text.
|
||||
|
||||
2. **中文摘要** in a collapsed `<details>` block, **immediately after the
|
||||
verdict**: verdict, A/B 结论, findings, 未覆盖范围. Collapsed, so it costs a
|
||||
reader who does not want it exactly one line; placed here rather than at
|
||||
@@ -1600,8 +1778,11 @@ defect.
|
||||
the verdict cannot be `merge-ready` — and the publisher enforces exactly
|
||||
that. When the unexpected failure is in the harness itself (a flaky probe,
|
||||
a broken A/B control cell) rather than in the PR's code, the verdict is
|
||||
`inconclusive`, not `findings` — `findings` stamps ❌ on the PR for a
|
||||
problem it did not cause.
|
||||
`inconclusive`, not `findings` — the failure is not the PR's. CI refuses
|
||||
`findings` only when the failures ledger below names no `pr` cause, and
|
||||
publishes the round as inconclusive under an untrusted-verdict note.
|
||||
Without a ledger it cannot tell, and stamps ❌ on the PR for a problem it
|
||||
did not cause.
|
||||
**Attribute every failure to a cause, and name the host as its own.**
|
||||
`assertions.json` carries `failures: [{ id, attribution }]`, one
|
||||
entry per failed assertion, with `attribution` one of `pr`, `harness` or
|
||||
|
||||
@@ -84,6 +84,7 @@ rules:
|
||||
|
||||
ignore:
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'deploy/kubernetes/chart/templates/'
|
||||
- 'thirdparty/'
|
||||
- 'third_party/'
|
||||
- 'vendor/'
|
||||
|
||||
+338
@@ -12,6 +12,344 @@ are listed; nightly and preview pre-releases are intentionally omitted.
|
||||
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
|
||||
> by hand — run `npm run changelog` to regenerate.
|
||||
|
||||
## [0.26.4](https://github.com/QwenLM/qwen-code/releases/tag/v0.26.4) - 2026-10-29
|
||||
|
||||
### Highlights
|
||||
|
||||
_See the complete change list below._
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
No known breaking changes.
|
||||
|
||||
### Complete Change List
|
||||
|
||||
#### Features
|
||||
|
||||
- feat(generated-ui): follow the host theme in generated views ([#15798](https://github.com/QwenLM/qwen-code/pull/15798)) by @qqqys
|
||||
- feat(ci): bound autofix takeover with a round budget ([#15388](https://github.com/QwenLM/qwen-code/pull/15388)) by @wenshao
|
||||
- feat(verify-pr): make "re-verify after update" a follow-up trigger ([#15816](https://github.com/QwenLM/qwen-code/pull/15816)) by @wenshao
|
||||
- feat(extensions): support unscoped npm installation and updates ([#15781](https://github.com/QwenLM/qwen-code/pull/15781)) by @callmeYe
|
||||
- feat(web-shell): move no-workspace sessions beside workspaces ([#15319](https://github.com/QwenLM/qwen-code/pull/15319)) by @deggs7
|
||||
- feat(managed): add independent worktrees for main sessions ([#15745](https://github.com/QwenLM/qwen-code/pull/15745)) by @doudouOUC
|
||||
- feat(web-shell): allow embedding hosts to pick workspace directories ([#15296](https://github.com/QwenLM/qwen-code/pull/15296)) by @jkxiao
|
||||
- feat(generated-ui): localize and reshape the card frame ([#15803](https://github.com/QwenLM/qwen-code/pull/15803)) by @qqqys
|
||||
- feat(web-shell): show model timing inline in Token Usage ([#15774](https://github.com/QwenLM/qwen-code/pull/15774)) by @qqqys
|
||||
- feat(cli): raise workspace skill install limits ([#15030](https://github.com/QwenLM/qwen-code/pull/15030)) by @callmeYe
|
||||
- feat(dws): configure quoted replies by conversation type ([#15019](https://github.com/QwenLM/qwen-code/pull/15019)) by @qqqys
|
||||
- feat(web-shell): present Generated UI cards as interactive views ([#15831](https://github.com/QwenLM/qwen-code/pull/15831)) by @qqqys
|
||||
- feat(autofix-pr): root watches at the session worktree under the daemon ([#15817](https://github.com/QwenLM/qwen-code/pull/15817)) by @wenshao
|
||||
- feat(managed-agent): add a phased upgrade command to the reference distribution ([#15792](https://github.com/QwenLM/qwen-code/pull/15792)) by @wenshao
|
||||
- feat(desktop): extend the PATH of the daemon the Desktop spawns on macOS ([#15190](https://github.com/QwenLM/qwen-code/pull/15190)) by @4ekuct25
|
||||
- feat(verify-pr): stamp model, CLI and skill version on published reports ([#15838](https://github.com/QwenLM/qwen-code/pull/15838)) by @wenshao
|
||||
- feat(cua): enable default cursor and live macOS PiP ([#15669](https://github.com/QwenLM/qwen-code/pull/15669)) by @LaZzyMan
|
||||
- feat(generated-ui): offer interactive views when users want to adjust values ([#15846](https://github.com/QwenLM/qwen-code/pull/15846)) by @qqqys
|
||||
- feat(acp): run the stop point of fresh prompts on the client's chain ([#15695](https://github.com/QwenLM/qwen-code/pull/15695)) by @qqqys
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- fix(config): report controls that were silently dropped ([#15794](https://github.com/QwenLM/qwen-code/pull/15794)) by @yiliang114
|
||||
- fix(cli): probe ICU before the UI import so an ICU-less Node fails loud ([#11753](https://github.com/QwenLM/qwen-code/pull/11753)) by @he-yufeng
|
||||
- fix(core): keep reasoning and function_call items adjacent through Responses cleanup ([#11684](https://github.com/QwenLM/qwen-code/pull/11684)) by @he-yufeng
|
||||
- fix(cli): carry branch points through the session load replay ([#13814](https://github.com/QwenLM/qwen-code/pull/13814)) by @he-yufeng
|
||||
- fix(web-shell): pause requests offline and recover automatically ([#14980](https://github.com/QwenLM/qwen-code/pull/14980)) by @ytahdn
|
||||
- fix(cli): map held pidfile lock and identity refusals to one stderr line ([#15818](https://github.com/QwenLM/qwen-code/pull/15818)) by @yiliang114
|
||||
- fix(release): unblock Managed runtime 0.26.3 backfill ([#15825](https://github.com/QwenLM/qwen-code/pull/15825)) by @yiliang114
|
||||
- fix(cli): prevent provider menu overlap in short terminals ([#15807](https://github.com/QwenLM/qwen-code/pull/15807)) by @yiliang114
|
||||
- fix(core): infer DeepSeek Flash effort on Alibaba proxies ([#15828](https://github.com/QwenLM/qwen-code/pull/15828)) by @callmeYe
|
||||
- fix(ci): exempt surefire-bound IT classes from the failsafe roster ([#15830](https://github.com/QwenLM/qwen-code/pull/15830)) by @yiliang114
|
||||
- fix(managed-agent): retry owed Hosted cleanup after its generation dies ([#15824](https://github.com/QwenLM/qwen-code/pull/15824)) by @wenshao
|
||||
- fix(verify-pr): bind reports to their round snapshot ([#15746](https://github.com/QwenLM/qwen-code/pull/15746)) by @jianyx1
|
||||
- fix(web-shell): standardize workspace and session display copy ([#15315](https://github.com/QwenLM/qwen-code/pull/15315)) by @deggs7
|
||||
- fix(web-shell): list and open standalone sessions in the Session Overview ([#13679](https://github.com/QwenLM/qwen-code/pull/13679)) by @4ekuct25
|
||||
- fix(cli): limit provider updates to the models served to the stored key ([#15230](https://github.com/QwenLM/qwen-code/pull/15230)) by @qqqys
|
||||
- fix(cli): return clean status for confirmed OpenTUI keyboard quits ([#15723](https://github.com/QwenLM/qwen-code/pull/15723)) by @chiga0
|
||||
- fix(web-shell): preserve model names after selection ([#15841](https://github.com/QwenLM/qwen-code/pull/15841)) by @callmeYe
|
||||
- fix(acp): keep setCoreValue resolving when the output-language write fails ([#15848](https://github.com/QwenLM/qwen-code/pull/15848)) by @yiliang114
|
||||
|
||||
#### Performance
|
||||
|
||||
- perf(web-templates): reclaim 92 KB of the HTML export renderer bundle ([#15778](https://github.com/QwenLM/qwen-code/pull/15778)) by @yiliang114
|
||||
- perf(managed): Bound activation renewal journal growth ([#15813](https://github.com/QwenLM/qwen-code/pull/15813)) by @doudouOUC
|
||||
|
||||
#### Documentation
|
||||
|
||||
- docs(managed): cite the shared-Session queue decision in the gate design ([#15797](https://github.com/QwenLM/qwen-code/pull/15797)) by @wenshao
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- test(managed-agent): pin HostedProcessLiveness.running on every host ([#15791](https://github.com/QwenLM/qwen-code/pull/15791)) by @wenshao
|
||||
- test: settle the two loaded-host waits from #15593 ([#15639](https://github.com/QwenLM/qwen-code/pull/15639)) by @2SinceJuly
|
||||
- test(core,cli): stop flat 30s budgets shadowing the raised ECS timeout ceiling ([#15801](https://github.com/QwenLM/qwen-code/pull/15801)) by @yiliang114
|
||||
- test(managed-agent): qualify test fixtures onto the managed JdbcTemplate ([#15809](https://github.com/QwenLM/qwen-code/pull/15809)) by @yiliang114
|
||||
- ci(java): remove MariaDB full integration lane ([#15827](https://github.com/QwenLM/qwen-code/pull/15827)) by @doudouOUC
|
||||
- test(managed-agent): bind the full-application suites to the managed beans ([#15802](https://github.com/QwenLM/qwen-code/pull/15802)) by @yiliang114
|
||||
- test(managed-agent): verify journal constraint rollback across databases ([#15842](https://github.com/QwenLM/qwen-code/pull/15842)) by @yiliang114
|
||||
|
||||
### New Contributors
|
||||
|
||||
- @2SinceJuly made their first contribution in [#15639](https://github.com/QwenLM/qwen-code/pull/15639)
|
||||
- @jianyx1 made their first contribution in [#15746](https://github.com/QwenLM/qwen-code/pull/15746)
|
||||
|
||||
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.26.3...v0.26.4
|
||||
|
||||
## [0.26.3](https://github.com/QwenLM/qwen-code/releases/tag/v0.26.3) - 2026-10-28
|
||||
|
||||
### Highlights
|
||||
|
||||
- Generated UI cards keep inputs and state across navigation and split views, show live views after initial calculation, and appear only for clients that can render MCP Apps. ([#15611](https://github.com/QwenLM/qwen-code/pull/15611), [#15787](https://github.com/QwenLM/qwen-code/pull/15787), [#15720](https://github.com/QwenLM/qwen-code/pull/15720), [#15790](https://github.com/QwenLM/qwen-code/pull/15790))
|
||||
- Persistent session Workflow mode can be enabled with --workflow-mode on, /workflows mode on, SDK, or ACP, and workflow switches now reach the CLI sandbox. ([#15422](https://github.com/QwenLM/qwen-code/pull/15422), [#15743](https://github.com/QwenLM/qwen-code/pull/15743))
|
||||
- Tool results shrink to remaining headroom before auto-compaction, and qwen-latest-series- or qwen4 aliases are detected with 1M-token context windows. ([#13599](https://github.com/QwenLM/qwen-code/pull/13599), [#15758](https://github.com/QwenLM/qwen-code/pull/15758), [#15762](https://github.com/QwenLM/qwen-code/pull/15762))
|
||||
- Valid XML tool calls are recovered when parameters quote other call markup, malformed blocks are isolated, and quoted examples are not dispatched. ([#13579](https://github.com/QwenLM/qwen-code/pull/13579))
|
||||
- ACP and daemon clients keep rewind identity consistent across retries, cancellations, checkpoints, recordings, and notifications, while SDK frames preserve subagent identity. ([#15441](https://github.com/QwenLM/qwen-code/pull/15441), [#15657](https://github.com/QwenLM/qwen-code/pull/15657))
|
||||
- /autofix-pr start stops when the PR head branch has unpushed commits or uncommitted changes, preventing the watch from overwriting unseen work. ([#15736](https://github.com/QwenLM/qwen-code/pull/15736))
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
No known breaking changes.
|
||||
|
||||
### Complete Change List
|
||||
|
||||
#### Features
|
||||
|
||||
- feat(memory): opt-in extraction cadence after a no-op run ([#13571](https://github.com/QwenLM/qwen-code/pull/13571)) by @yiliang114
|
||||
- Shrinks tool results to remaining headroom before auto-compaction, respecting explicit truncation settings and protected content to reduce unnecessary compaction. ([#13599](https://github.com/QwenLM/qwen-code/pull/13599)) by @yiliang114
|
||||
- Keeps eligible Generated UI cards alive across history navigation, session switching, and split-view changes in the same page, preserving inputs, controls, and runtime state. ([#15611](https://github.com/QwenLM/qwen-code/pull/15611)) by @qqqys
|
||||
- Adds persistent session Workflow mode through --workflow-mode on, /workflows mode on, SDK, and ACP controls, with saved mode shown in Ink and OpenTUI. ([#15422](https://github.com/QwenLM/qwen-code/pull/15422)) by @qqqys
|
||||
- Adds opt-in composerVariant simple to Web Shell, providing a compact input with a limited add menu and shared editor, attachments, and session actions. ([#15663](https://github.com/QwenLM/qwen-code/pull/15663)) by @samuelhsin
|
||||
- /autofix-pr start now stops if the PR head branch has unpushed commits or uncommitted changes, preventing the watch from overwriting unseen work. ([#15736](https://github.com/QwenLM/qwen-code/pull/15736)) by @wenshao
|
||||
- Adds administrator-driven reclamation and reuse of idle resident CSI Workspaces with cleanup verification and atomic release. ([#15729](https://github.com/QwenLM/qwen-code/pull/15729)) by @doudouOUC
|
||||
- OpenTUI terminals now draw the Mods band above the prompt and run Mod ui.render hooks there, matching the Ink terminal experience. ([#15699](https://github.com/QwenLM/qwen-code/pull/15699)) by @qqqys
|
||||
- ACP live frames, persisted replays, SDK normalization, and transcript reduction now preserve subagent identity for thoughts, messages, and tool frames. ([#15657](https://github.com/QwenLM/qwen-code/pull/15657)) by @yiliang114
|
||||
- Archived conversations are now managed in Settings → Archived conversations in Web Shell, keeping archive groups, filters, and lifecycle actions. ([#15557](https://github.com/QwenLM/qwen-code/pull/15557)) by @deggs7
|
||||
- Managed Agent can now be embedded opt-in in a Java host's JVM and Spring context, with dedicated connection pooling or a supplied DataSource. ([#15718](https://github.com/QwenLM/qwen-code/pull/15718)) by @yiliang114
|
||||
- /verify-pr now supports a failures ledger and rejects findings or blocked verdicts unless failures are attributed to the PR. ([#15764](https://github.com/QwenLM/qwen-code/pull/15764)) by @wenshao
|
||||
- Adds the qwen-managed-agent-admin launcher for running managed-agent offline maintenance commands by name, with help and unknown-command handling. ([#15772](https://github.com/QwenLM/qwen-code/pull/15772)) by @wenshao
|
||||
- feat(generated-ui): check a view's initial calculation before display ([#15790](https://github.com/QwenLM/qwen-code/pull/15790)) by @qqqys
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- Recovers valid XML tool calls when parameters quote other call markup, isolates malformed blocks, and prevents quoted examples from being dispatched. ([#13579](https://github.com/QwenLM/qwen-code/pull/13579)) by @yiliang114
|
||||
- Carries rewind identity through ACP/daemon clients so retries, cancellations, checkpoint resolution, recording, and notifications stay consistent. ([#15441](https://github.com/QwenLM/qwen-code/pull/15441)) by @yiliang114
|
||||
- Fixes release publishing checkout failures by using the run-scoped GitHub token for read-only checkout while keeping the bot PAT for write steps. ([#15728](https://github.com/QwenLM/qwen-code/pull/15728)) by @qwen-code-dev-bot
|
||||
- Pins pull request CI retries to the head SHA from the triggering event so reruns test the intended commit. ([#15734](https://github.com/QwenLM/qwen-code/pull/15734)) by @yiliang114
|
||||
- fix(cli): forward workflow switches into the CLI sandbox (#15740) ([#15743](https://github.com/QwenLM/qwen-code/pull/15743)) by @qwen-code-dev-bot
|
||||
- Recognizes invite-only qwen-latest-series- model aliases as 1M-context models in the context-window detector. ([#15758](https://github.com/QwenLM/qwen-code/pull/15758)) by @wenshao
|
||||
- Makes daemon-managed channels honor approvalMode even when no webhooks are configured, matching standalone startup. ([#15754](https://github.com/QwenLM/qwen-code/pull/15754)) by @qqqys
|
||||
- Shows the exception class in managed-agent scan summaries for non-first row failures that have no message. ([#15725](https://github.com/QwenLM/qwen-code/pull/15725)) by @yiliang114
|
||||
- Workspace Skill inventory queries now return a retained snapshot immediately and refresh in the background, while cold reads, deletion, and enablement wait for fresh data. ([#14890](https://github.com/QwenLM/qwen-code/pull/14890)) by @callmeYe
|
||||
- qwen4 model identifiers are now sized with a 1,000,000-token context window by default when no upstream catalog entry defines them. ([#15762](https://github.com/QwenLM/qwen-code/pull/15762)) by @wenshao
|
||||
- Acceptance tests now wait for the focused shell Tab byte to reach the child process before asserting exact input bytes, preventing flaky failures. ([#15763](https://github.com/QwenLM/qwen-code/pull/15763)) by @yiliang114
|
||||
- Managed runtime distribution now publishes verified server artifacts to GitHub Releases and OSS without requiring Maven Central, keeping CLI downloads unchanged. ([#15782](https://github.com/QwenLM/qwen-code/pull/15782)) by @doudouOUC
|
||||
- Generated UI is now offered only to sessions whose client can render MCP Apps, while unsupported clients receive a clear unsupported-client reason. ([#15720](https://github.com/QwenLM/qwen-code/pull/15720)) by @qqqys
|
||||
- In Web Shell, live Generated UI cards now attach and show their interactive view immediately when tool results arrive instead of requiring reopen. ([#15787](https://github.com/QwenLM/qwen-code/pull/15787)) by @qqqys
|
||||
- Desktop release preparation now resets Git metadata on self-hosted runners before checkout to avoid stale-workspace failures. ([#15761](https://github.com/QwenLM/qwen-code/pull/15761)) by @yiliang114
|
||||
|
||||
#### Documentation
|
||||
|
||||
- Documents managed-agent shared-Session queue behavior and advises giving slow-probed gated definitions their own Session. ([#15724](https://github.com/QwenLM/qwen-code/pull/15724)) by @yiliang114
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- Internal tests now compare git worktree prune dry-run results as an unordered set, making them insensitive to git output order. ([#15767](https://github.com/QwenLM/qwen-code/pull/15767)) by @yiliang114
|
||||
- Managed snapshot pool test setup now allows a three-second database connection lease so schema creation on busy CI runners is less likely to fail. ([#15749](https://github.com/QwenLM/qwen-code/pull/15749)) by @yiliang114
|
||||
- Installed npm and private Bun acceptance tests now run nightly and by manual dispatch instead of on every PR update, while source TUI PR behavior stays unchanged. ([#15719](https://github.com/QwenLM/qwen-code/pull/15719)) by @yiliang114
|
||||
- Improved Managed Agent reliability testing by adding terminal recovery fault gates for failover scenarios. ([#15756](https://github.com/QwenLM/qwen-code/pull/15756)) by @wenshao
|
||||
- Improved CLI test stability for review cleanup orphan-process handling by making process liveness deterministic. ([#15751](https://github.com/QwenLM/qwen-code/pull/15751)) by @yiliang114
|
||||
- Hosted gates now use a shared zombie-aware liveness check, improving consistency when waiting for processes to stop. ([#15777](https://github.com/QwenLM/qwen-code/pull/15777)) by @wenshao
|
||||
|
||||
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.26.2...v0.26.3
|
||||
|
||||
## [0.26.2](https://github.com/QwenLM/qwen-code/releases/tag/v0.26.2) - 2026-10-27
|
||||
|
||||
### Highlights
|
||||
|
||||
_See the complete change list below._
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- fix(managed-agent)!: simplify Web Shell controls and retire cwd submissions ([#15616](https://github.com/QwenLM/qwen-code/pull/15616)) by @doudouOUC
|
||||
|
||||
### Complete Change List
|
||||
|
||||
#### Features
|
||||
|
||||
- feat(managed): add session permission rules and Auto approval ([#15448](https://github.com/QwenLM/qwen-code/pull/15448)) by @doudouOUC
|
||||
- feat(core): add an experimental shared agent loop and run headless on it ([#15369](https://github.com/QwenLM/qwen-code/pull/15369)) by @qqqys
|
||||
- Adds X-DashScope-Source-Config to matching QwenCloud and Qianwen AI Platform requests to identify site and client, without changing authentication or retries. ([#15041](https://github.com/QwenLM/qwen-code/pull/15041)) by @yiliang114
|
||||
- Resumed OpenTUI sessions now retain recorded text, images, tool results, and prompt identities, while rewind restores transcript, request, and draft. ([#15384](https://github.com/QwenLM/qwen-code/pull/15384)) by @chiga0
|
||||
- OpenTUI now captures complete checkpoints at edit approvals and restores them safely, preserving conversation metadata and blocking stale or cancelled owners. ([#15389](https://github.com/QwenLM/qwen-code/pull/15389)) by @chiga0
|
||||
- Adds automation.scanner-threads to run managed automation scanner rows on a dedicated pool and leases rows from claim time. ([#15442](https://github.com/QwenLM/qwen-code/pull/15442)) by @yiliang114
|
||||
- Adds Continue analysis with current parameters so Generated UI users can review and send frozen parameters to the original session. ([#15333](https://github.com/QwenLM/qwen-code/pull/15333)) by @qqqys
|
||||
- Adds an opt-in external-scratch Runtime provider that lets an operator-run HTTP service place Session workers while preserving attestation and recovery. ([#15261](https://github.com/QwenLM/qwen-code/pull/15261)) by @yiliang114
|
||||
- Adds a prompt argument hint for /verify-pr so users can see the PR number and supported flags such as --publish and --watch. ([#15463](https://github.com/QwenLM/qwen-code/pull/15463)) by @wenshao
|
||||
- Adds a durable queue for messages sent during a Managed Turn, allowing queued text and file references to be delivered, edited, withdrawn, or resumed safely. ([#15449](https://github.com/QwenLM/qwen-code/pull/15449)) by @doudouOUC
|
||||
- Adds /autofix-pr status to list all watches in a clone and lets users resume a watch in a later session. ([#15510](https://github.com/QwenLM/qwen-code/pull/15510)) by @wenshao
|
||||
- Adds a Git URL source to the Web Shell Add-workspace dialog so users can clone a repository and open it as a workspace in the browser. ([#15361](https://github.com/QwenLM/qwen-code/pull/15361)) by @wenshao
|
||||
- Enables image and PDF delivery for managed runtime providers with a 768 KiB base64 media limit and a 896 KiB tool-result limit. ([#13606](https://github.com/QwenLM/qwen-code/pull/13606)) by @water-in-stone
|
||||
- feat(managed-agent): give the Hosted Harness its Broker through the environment ([#15244](https://github.com/QwenLM/qwen-code/pull/15244)) by @wenshao
|
||||
- Adds an operator endpoint to register immutable, idempotent EXTERNAL Workspace storage ownership with provider identity and tenant alias. ([#15516](https://github.com/QwenLM/qwen-code/pull/15516)) by @yiliang114
|
||||
- UserPromptSubmit hooks no longer re-append byte-identical additionalContext within a session, avoiding duplicate context and keeping transcripts accurate. ([#14129](https://github.com/QwenLM/qwen-code/pull/14129)) by @yiliang114
|
||||
- Mods can now observe turn.start and turn.complete events in headless and terminal runs, and use $.turn.abort to end a running turn. ([#15443](https://github.com/QwenLM/qwen-code/pull/15443)) by @qqqys
|
||||
- Web Shell Settings now lets you list, add, and remove extra workspace directories, applying changes to live sessions and informing the model of new roots. ([#15393](https://github.com/QwenLM/qwen-code/pull/15393)) by @wenshao
|
||||
- feat(autofix-pr): run the helpers through a tool, and let a confirmed watch's fixes run unattended ([#15548](https://github.com/QwenLM/qwen-code/pull/15548)) by @wenshao
|
||||
- feat(managed-agent): let the bootstrap set the service account and MySQL up ([#15543](https://github.com/QwenLM/qwen-code/pull/15543)) by @wenshao
|
||||
- feat(managed-agent): add pinned extension package management ([#15490](https://github.com/QwenLM/qwen-code/pull/15490)) by @doudouOUC
|
||||
- feat(managed-agent): support Workspace file uploads ([#15586](https://github.com/QwenLM/qwen-code/pull/15586)) by @doudouOUC
|
||||
- feat(core): Add durable terminal identity lookup ([#15566](https://github.com/QwenLM/qwen-code/pull/15566)) by @doudouOUC
|
||||
- When experimental.unifiedAgentLoop is enabled, fresh ACP user prompts run their model round on the shared agent loop while ACP keeps its own tool-step handling. ([#15509](https://github.com/QwenLM/qwen-code/pull/15509)) by @qqqys
|
||||
- The Mod runtime adds ui.render, $.ui.resolve, and $.ui.invalidate support, enabling render-site hooks and terminal element resolution before hosts draw UI. ([#15591](https://github.com/QwenLM/qwen-code/pull/15591)) by @qqqys
|
||||
- feat(web-shell): add plain-text and Markdown reply copying ([#15345](https://github.com/QwenLM/qwen-code/pull/15345)) by @ytahdn
|
||||
- feat(cli): connect OpenTUI voice input to the shared pipeline ([#15617](https://github.com/QwenLM/qwen-code/pull/15617)) by @chiga0
|
||||
- feat(ci): canary-split the PR review model by PR number ([#15538](https://github.com/QwenLM/qwen-code/pull/15538)) by @yiliang114
|
||||
- feat(daemon): add cached workspace artifact queries and batch removal ([#15594](https://github.com/QwenLM/qwen-code/pull/15594)) by @BZ-D
|
||||
- feat(managed-agent): add native snapshots and HTTP recovery ([#15569](https://github.com/QwenLM/qwen-code/pull/15569)) by @doudouOUC
|
||||
- feat(cli): admit Managed Runtime boot-v2 with external storage and publication surface in container mode ([#15522](https://github.com/QwenLM/qwen-code/pull/15522)) by @yiliang114
|
||||
- feat(tools): let an explicitly configured search backend name its host ([#15638](https://github.com/QwenLM/qwen-code/pull/15638)) by @yiliang114
|
||||
- feat(cli): align OpenTUI foreground shell controls with Ink ([#15642](https://github.com/QwenLM/qwen-code/pull/15642)) by @chiga0
|
||||
- feat(acp): run the tool batches of fresh prompts on the shared agent loop ([#15627](https://github.com/QwenLM/qwen-code/pull/15627)) by @qqqys
|
||||
- feat(serve): allow secondary sessions to cd within their workspace ([#15323](https://github.com/QwenLM/qwen-code/pull/15323)) by @rockybot2026
|
||||
- feat(mods): draw the band above the prompt in the Ink terminal ([#15619](https://github.com/QwenLM/qwen-code/pull/15619)) by @qqqys
|
||||
- feat(autofix-pr): run an unattended watch's fix agent in the background ([#15578](https://github.com/QwenLM/qwen-code/pull/15578)) by @wenshao
|
||||
- feat(core): add local managed journal snapshot recovery ([#15626](https://github.com/QwenLM/qwen-code/pull/15626)) by @doudouOUC
|
||||
- feat(verify-pr): add a local preflight and a verdict check ([#15614](https://github.com/QwenLM/qwen-code/pull/15614)) by @wenshao
|
||||
- feat(web-shell): show /autofix-pr watches and stop them from the Web Shell ([#15668](https://github.com/QwenLM/qwen-code/pull/15668)) by @wenshao
|
||||
- feat(mods): compile JSX in .tsx and .jsx Mod modules ([#15624](https://github.com/QwenLM/qwen-code/pull/15624)) by @qqqys
|
||||
- feat(cli): bind the PR named by a /verify-pr prompt ([#15625](https://github.com/QwenLM/qwen-code/pull/15625)) by @wenshao
|
||||
- feat(cli): complete OpenTUI extension discovery and source management ([#15671](https://github.com/QwenLM/qwen-code/pull/15671)) by @chiga0
|
||||
- feat(managed-agent): add the external-workspace provisioning prerequisite (#15512) ([#15654](https://github.com/QwenLM/qwen-code/pull/15654)) by @yiliang114
|
||||
- feat(web-shell): nest derived sessions under their parent in the sidebar ([#15714](https://github.com/QwenLM/qwen-code/pull/15714)) by @wenshao
|
||||
- feat(web-shell): name new sessions from the first prompt ([#15689](https://github.com/QwenLM/qwen-code/pull/15689)) by @wenshao
|
||||
- feat(managed-agent): check Harness compatibility, leftover workers and a live service in preflight ([#15564](https://github.com/QwenLM/qwen-code/pull/15564)) by @wenshao
|
||||
- feat(web-shell): stream BTW answers with reconnect and scroll following ([#15709](https://github.com/QwenLM/qwen-code/pull/15709)) by @ytahdn
|
||||
- feat(cli): run only the tests related to the diff in review build-test ([#15547](https://github.com/QwenLM/qwen-code/pull/15547)) by @wenshao
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- fix(cli): harden serve token resolution against project env injection ([#15228](https://github.com/QwenLM/qwen-code/pull/15228)) by @yiliang114
|
||||
- fix(cli): align OpenTUI cancellation and retry with Ink ([#15283](https://github.com/QwenLM/qwen-code/pull/15283)) by @chiga0
|
||||
- test(managed-agent): assert web_fetch in the Hosted workspace gate fixture ([#15451](https://github.com/QwenLM/qwen-code/pull/15451)) by @yiliang114
|
||||
- fix(vscode): leave edit mode after a refused edit ([#15439](https://github.com/QwenLM/qwen-code/pull/15439)) by @yiliang114
|
||||
- fix(rewind): resolve legacy turns by source identity ([#13737](https://github.com/QwenLM/qwen-code/pull/13737)) by @yiliang114
|
||||
- Retires registered Linux zombie workers before startup waits, preventing a 30-second delay and returning a prompt terminal conflict instead. ([#15459](https://github.com/QwenLM/qwen-code/pull/15459)) by @doudouOUC
|
||||
- Preserves arrival order of direct-mode stream-json startup messages so pipelined client requests execute and return results in the intended order. ([#15378](https://github.com/QwenLM/qwen-code/pull/15378)) by @yiliang114
|
||||
- Restores managed-agent module and CLI build lanes by correcting the schema inventory column total after new permission-rule columns were added. ([#15455](https://github.com/QwenLM/qwen-code/pull/15455)) by @yiliang114
|
||||
- Makes the team-lead Hosted MySQL integration boot retry when the harness loses its reserved port, reducing flaky test timeouts. ([#15224](https://github.com/QwenLM/qwen-code/pull/15224)) by @qwen-code-dev-bot
|
||||
- CLI checkpoint restores now preserve ownership, Read permissions, and prompt history so cold resume and later rewind behave consistently. ([#15386](https://github.com/QwenLM/qwen-code/pull/15386)) by @chiga0
|
||||
- Eligible npm sessions using OpenTUI now run on private Bun 1.4.4 while preserving Node for npm subprocesses and existing encrypted settings. ([#15387](https://github.com/QwenLM/qwen-code/pull/15387)) by @chiga0
|
||||
- The main CI failure reporter now files issues even when a newer main run is still pending, adding a provisional note instead of dropping the failure. ([#15471](https://github.com/QwenLM/qwen-code/pull/15471)) by @yiliang114
|
||||
- Web Shell permission-rule declarations are now self-contained so package publishing no longer fails on unresolvable core-module imports. ([#15501](https://github.com/QwenLM/qwen-code/pull/15501)) by @yiliang114
|
||||
- CLI preserves OpenTUI clipboard source attribution and collapsed large pastes during draft recovery, queue restoration, cancellation, and recall. ([#15396](https://github.com/QwenLM/qwen-code/pull/15396)) by @chiga0
|
||||
- Hosted MySQL test isolation preserves the original table-truncation error when foreign-key restore also fails. ([#15505](https://github.com/QwenLM/qwen-code/pull/15505)) by @yiliang114
|
||||
- CLI cost estimates no longer double-count reasoning tokens for OpenAI Chat Completions, OpenAI Responses, and Qwen OAuth usage. ([#15464](https://github.com/QwenLM/qwen-code/pull/15464)) by @yiliang114
|
||||
- Release automation limits release-note summary requests to three concurrent waves, keeps result order, and refreshes release PRs from main before automated approvals. ([#15500](https://github.com/QwenLM/qwen-code/pull/15500)) by @yiliang114
|
||||
- Managed Web Shell prevents duplicate message composers and keeps drafts stable during Session polling or permission-rule rerenders. ([#15496](https://github.com/QwenLM/qwen-code/pull/15496)) by @doudouOUC
|
||||
- On Linux, Web Shell no longer shows the native directory Browse button when the X11 display is unreachable. ([#15434](https://github.com/QwenLM/qwen-code/pull/15434)) by @yiliang114
|
||||
- Raises timeout budgets for the standalone Node/Bun regression suite on the release pool and cleans up outdated exemptions. ([#15102](https://github.com/QwenLM/qwen-code/pull/15102)) by @qwen-code-dev-bot
|
||||
- Refuses to start pull-request autofix watch from a checkout that cannot run it and reports the required working directory. ([#15506](https://github.com/QwenLM/qwen-code/pull/15506)) by @wenshao
|
||||
- Simplifies Web Shell status display when loading phrases are hidden and improves status text contrast. ([#15423](https://github.com/QwenLM/qwen-code/pull/15423)) by @deggs7
|
||||
- Keeps incremental lint history fetching lightweight and surfaces fallback as a CI warning and step summary. ([#15511](https://github.com/QwenLM/qwen-code/pull/15511)) by @yiliang114
|
||||
- Gives eligible main push SHAs separate CI concurrency groups so independent main validations are not displaced by newer pending runs. ([#15508](https://github.com/QwenLM/qwen-code/pull/15508)) by @yiliang114
|
||||
- Authenticates managed-runtime OSS preflight checks so release publishing can verify existing artifacts and avoid failed uploads. ([#15504](https://github.com/QwenLM/qwen-code/pull/15504)) by @yiliang114
|
||||
- Runs the self-hosted SDK Java test suite before taking the host lock to reduce timeouts and lock contention in CI. ([#15254](https://github.com/QwenLM/qwen-code/pull/15254)) by @qwen-code-dev-bot
|
||||
- Prevents the main CI failure tracker from filing issues for post-merge PR reruns that do not reflect the merged tree. ([#15250](https://github.com/QwenLM/qwen-code/pull/15250)) by @qwen-code-dev-bot
|
||||
- Hides unavailable Managed background tools so Background Shell and Monitor appear only when the session can run them. ([#15497](https://github.com/QwenLM/qwen-code/pull/15497)) by @doudouOUC
|
||||
- Fixes a managed-agent shutdown error caused by lifecycle renewal scheduling. ([#15231](https://github.com/QwenLM/qwen-code/pull/15231)) by @qwen-code-dev-bot
|
||||
- Unblocks independent same-tenant resident workspaces blocked by an unreclaimed binding and fixes hosted file tool admission. ([#15457](https://github.com/QwenLM/qwen-code/pull/15457)) by @doudouOUC
|
||||
- In Web Shell standalone sessions, Language: UI is now a user setting, so changing it no longer fails with workspace-scope errors. ([#15521](https://github.com/QwenLM/qwen-code/pull/15521)) by @wenshao
|
||||
- Failed queued automatic memory extraction now logs a debug warning instead of causing an unhandled rejection. ([#15338](https://github.com/QwenLM/qwen-code/pull/15338)) by @wenshao
|
||||
- Credential-free release preparation jobs now run on shared ECS runners in the official repository, with existing fallbacks preserved. ([#15517](https://github.com/QwenLM/qwen-code/pull/15517)) by @yiliang114
|
||||
- Headless CLI retries now show a retry classification on stderr, making transport failures and restart reasons easier to diagnose. ([#15515](https://github.com/QwenLM/qwen-code/pull/15515)) by @yiliang114
|
||||
- In Web Shell, /autofix-pr watches now persist as durable tasks, so they survive idle session reclamation and appear in Scheduled tasks. ([#15523](https://github.com/QwenLM/qwen-code/pull/15523)) by @wenshao
|
||||
- Deletes a turn's memory catalog snapshot record when the turn is removed, preventing orphaned snapshots while preserving catalog continuity. ([#15540](https://github.com/QwenLM/qwen-code/pull/15540)) by @yiliang114
|
||||
- Fixes ARM64 CI lane pricing so self-hosted lanes get correct ceilings: lint 45 to 90 minutes and no-AK integration 30 to 60 minutes. ([#15529](https://github.com/QwenLM/qwen-code/pull/15529)) by @yiliang114
|
||||
- Hosted sessions now persist Todo and project committed task lists, showing them in Managed WebShell so later turns use the latest state. ([#15155](https://github.com/QwenLM/qwen-code/pull/15155)) by @yiliang114
|
||||
- Incremental lint CI now deepens history until the merge base is provable, reducing unnecessary full-repository scans on long-lived pull requests. ([#15525](https://github.com/QwenLM/qwen-code/pull/15525)) by @yiliang114
|
||||
- Incremental Prettier checks now group files by the nearest configuration directory, so formatting rules and ignores apply correctly across touched paths. ([#15531](https://github.com/QwenLM/qwen-code/pull/15531)) by @yiliang114
|
||||
- Kubernetes Harness pool now passes qwen serve the managed runtime broker URL and token environment pair, with clearer errors for missing, invalid, or non-HTTPS Broker URLs. ([#15394](https://github.com/QwenLM/qwen-code/pull/15394)) by @wenshao
|
||||
- The tui-parity Check TUI impact gate now runs on the persistent ECS pool for trusted PRs, while untrusted fork PRs stay on GitHub-hosted runners. ([#15561](https://github.com/QwenLM/qwen-code/pull/15561)) by @yiliang114
|
||||
- UserPromptSubmit hook context is no longer duplicated on the next turn after automatic compaction, and compression checkpoints are protected from later rescue mutations. ([#15575](https://github.com/QwenLM/qwen-code/pull/15575)) by @yiliang114
|
||||
- The Mod runtime now targets Claude Code 2.1.310, accepts tool.check answers with setting fields, and reports that version from $.session.version(). ([#15560](https://github.com/QwenLM/qwen-code/pull/15560)) by @qqqys
|
||||
- UserPromptSubmit hook context remains deduplicated after memory catalog snapshots or added message parts, preventing repeated hook context copies in CLI requests. ([#15556](https://github.com/QwenLM/qwen-code/pull/15556)) by @yiliang114
|
||||
- fix(ci): resolve the pool's Maven dependencies through a domestic mirror ([#15610](https://github.com/QwenLM/qwen-code/pull/15610)) by @yiliang114
|
||||
- fix(ci): stop failing the Maven bootstrap on a rate-limited pool egress ([#15539](https://github.com/QwenLM/qwen-code/pull/15539)) by @yiliang114
|
||||
- fix(managed-agent): retry read-only attestation after EOF ([#15608](https://github.com/QwenLM/qwen-code/pull/15608)) by @doudouOUC
|
||||
- fix(ci): resolve libfaketime for the host architecture ([#15609](https://github.com/QwenLM/qwen-code/pull/15609)) by @yiliang114
|
||||
- fix(ci): bound setup-java on the pool and fall back to the tool cache ([#15605](https://github.com/QwenLM/qwen-code/pull/15605)) by @yiliang114
|
||||
- fix(web-shell): match cancellation duration to turn summary ([#15615](https://github.com/QwenLM/qwen-code/pull/15615)) by @ytahdn
|
||||
- fix(web-shell): hide turn navigation without sufficient gutters ([#15604](https://github.com/QwenLM/qwen-code/pull/15604)) by @ytahdn
|
||||
- fix(core): stop warning about a placeholder in the attribution header ([#15636](https://github.com/QwenLM/qwen-code/pull/15636)) by @yiliang114
|
||||
- fix(ci): resolve the pool's Maven dependencies from the intranet mirror ([#15641](https://github.com/QwenLM/qwen-code/pull/15641)) by @yiliang114
|
||||
- fix: preserve interleaved reasoning in TUI and Chat replay ([#15621](https://github.com/QwenLM/qwen-code/pull/15621)) by @DragonnZhang
|
||||
- fix(cli): preserve previews and verify installed OpenTUI distribution ([#15655](https://github.com/QwenLM/qwen-code/pull/15655)) by @chiga0
|
||||
- fix(ci): Isolate Managed runtime release test classes ([#15676](https://github.com/QwenLM/qwen-code/pull/15676)) by @doudouOUC
|
||||
- fix(ci): deepen until the lint scope's own acceptance holds ([#15644](https://github.com/QwenLM/qwen-code/pull/15644)) by @yiliang114
|
||||
- fix(release): extend standalone runtime download retries ([#15673](https://github.com/QwenLM/qwen-code/pull/15673)) by @qwen-code-dev-bot
|
||||
- fix(ci): preserve setup-java JDKs during pool fallback ([#15623](https://github.com/QwenLM/qwen-code/pull/15623)) by @yiliang114
|
||||
- fix(ci): keep the sdk-java cache write-back on a failed suite ([#15555](https://github.com/QwenLM/qwen-code/pull/15555)) by @wenshao
|
||||
- fix(serve): Fix Hosted local state and foreground Shell validation ([#15670](https://github.com/QwenLM/qwen-code/pull/15670)) by @doudouOUC
|
||||
- fix(release): close the failure issue once a re-run passes ([#15711](https://github.com/QwenLM/qwen-code/pull/15711)) by @yiliang114
|
||||
- fix(ci): refresh the stale ci.yml workflow-size baseline ([#15717](https://github.com/QwenLM/qwen-code/pull/15717)) by @wenshao
|
||||
- fix(web-shell): keep session rename responsive ([#15688](https://github.com/QwenLM/qwen-code/pull/15688)) by @wenshao
|
||||
- fix(cli): preserve OpenTUI extension update state ([#15716](https://github.com/QwenLM/qwen-code/pull/15716)) by @chiga0
|
||||
- fix(ci): Preserve canonical artifacts across job retries ([#15713](https://github.com/QwenLM/qwen-code/pull/15713)) by @doudouOUC
|
||||
|
||||
#### Performance
|
||||
|
||||
- Runs web-shell and standalone tests concurrently with the main CLI test suite to shorten required Linux CI time. ([#15438](https://github.com/QwenLM/qwen-code/pull/15438)) by @wenshao
|
||||
- Keeps unchanged legacy memory catalog snapshots stable in history to improve DashScope cache reuse, resume, and retry behavior. ([#15246](https://github.com/QwenLM/qwen-code/pull/15246)) by @yiliang114
|
||||
|
||||
#### Documentation
|
||||
|
||||
- Updates scheduled-task gate examples so copied first-run commands include required repository and PR options. ([#15452](https://github.com/QwenLM/qwen-code/pull/15452)) by @sxl-lucky7
|
||||
- docs(design): external Hosted Workspace profile (phase 2) ([#15520](https://github.com/QwenLM/qwen-code/pull/15520)) by @yiliang114
|
||||
- docs(managed): describe gate scanner capacity under the row pool ([#15571](https://github.com/QwenLM/qwen-code/pull/15571)) by @wenshao
|
||||
- docs: clarify usage statistics across authentication methods ([#15554](https://github.com/QwenLM/qwen-code/pull/15554)) by @sxl-lucky7
|
||||
- docs(qwencloud): document the forced attribution header and pin the admission split ([#15633](https://github.com/QwenLM/qwen-code/pull/15633)) by @yiliang114
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- test(cli): cover remaining Managed Workspace activation refusals ([#15460](https://github.com/QwenLM/qwen-code/pull/15460)) by @doudouOUC
|
||||
- Corrects the managed-agent CSI inventory column count so test suites pass after permission-rule columns were added. ([#15469](https://github.com/QwenLM/qwen-code/pull/15469)) by @yiliang114
|
||||
- test(ci): pin the disk-pressure collector revision and FULL_PAYLOAD run bodies ([#15475](https://github.com/QwenLM/qwen-code/pull/15475)) by @yiliang114
|
||||
- Adds an integration test verifying the removeScratchDir retry budget removes busy scratch directories without logging cleanup warnings. ([#15488](https://github.com/QwenLM/qwen-code/pull/15488)) by @yiliang114
|
||||
- Tests now capture expected disk-full log output so real CI disk-full failures are easier to distinguish from deliberate test errors. ([#8549](https://github.com/QwenLM/qwen-code/pull/8549)) by @EthDing
|
||||
- Managed Agent tests now wait for a dead worker to become unusable before checking lease identity reuse or reconciling its loss. ([#15503](https://github.com/QwenLM/qwen-code/pull/15503)) by @yiliang114
|
||||
- SDK Java CI retains Surefire and Failsafe failure reports and preserves original SQL causes in channel ownership race tests. ([#14910](https://github.com/QwenLM/qwen-code/pull/14910)) by @Aling33
|
||||
- Adds CI regression coverage to keep JUnit report paths where CI collects them. ([#15502](https://github.com/QwenLM/qwen-code/pull/15502)) by @barry166
|
||||
- Applies the slow-host policy to hosted approval test waits so slow runners get longer approval-test timeouts. ([#15183](https://github.com/QwenLM/qwen-code/pull/15183)) by @qwen-code-dev-bot
|
||||
- Adjusts hosted approval test waits based on runner load policies to improve CI reliability on slower hosts. ([#15101](https://github.com/QwenLM/qwen-code/pull/15101)) by @qwen-code-dev-bot
|
||||
- Adds automated checks for memory recovery and hard-rescue catalog accounting to prevent regressions. ([#15535](https://github.com/QwenLM/qwen-code/pull/15535)) by @yiliang114
|
||||
- Adds automated checks for managed-agent row pool fan-out and corrects design documentation on shutdown and task limits. ([#15528](https://github.com/QwenLM/qwen-code/pull/15528)) by @yiliang114
|
||||
- Adds a repeatable Harness Kubernetes qualification workflow for deploying, verifying, and exercising shared pools. ([#15519](https://github.com/QwenLM/qwen-code/pull/15519)) by @doudouOUC
|
||||
- test(cli): pin the lifecycle-protocol refusal on the load route ([#15536](https://github.com/QwenLM/qwen-code/pull/15536)) by @yiliang114
|
||||
- Adds tests verifying the lint --setup guard installs only required linters and installs nothing when no lane is selected. ([#15533](https://github.com/QwenLM/qwen-code/pull/15533)) by @yiliang114
|
||||
- Consolidates catalog visibility rules so curated history reads consistently hide catalog snapshots when legacy memory is suppressed. ([#15534](https://github.com/QwenLM/qwen-code/pull/15534)) by @yiliang114
|
||||
- Centralizes QwenCloud MaaS host list usage for telemetry classification and DashScope admission to keep host handling consistent. ([#15532](https://github.com/QwenLM/qwen-code/pull/15532)) by @yiliang114
|
||||
- Hosted harness tests now use longer adaptive wait budgets for interrupted-model checks, reducing flaky timeouts without changing asserted behavior. ([#15551](https://github.com/QwenLM/qwen-code/pull/15551)) by @yiliang114
|
||||
- test(managed-agent): align S1 acceptance fixtures with runtime contracts ([#15585](https://github.com/QwenLM/qwen-code/pull/15585)) by @doudouOUC
|
||||
- Managed Agent workspace upload admission tests now use valid binary upload parameters and upload-status queries, and expect precise not-found errors for unknown uploads. ([#15588](https://github.com/QwenLM/qwen-code/pull/15588)) by @doudouOUC
|
||||
- test(runtime-broker): build the extension-profile fixture paths portably ([#15598](https://github.com/QwenLM/qwen-code/pull/15598)) by @yiliang114
|
||||
- test(cli): budget the skill-recovery poll through the settle policy ([#15589](https://github.com/QwenLM/qwen-code/pull/15589)) by @yiliang114
|
||||
- test(lint): pin the .gitignore grouping arm and the batch stride ([#15640](https://github.com/QwenLM/qwen-code/pull/15640)) by @yiliang114
|
||||
- Pin unknown sed long options ([#15223](https://github.com/QwenLM/qwen-code/pull/15223)) by @waybarrios
|
||||
- ci: run every suite but CLI and core in the side test lane ([#15570](https://github.com/QwenLM/qwen-code/pull/15570)) by @wenshao
|
||||
- test(cli): read xn-- hosts by the running URL parser in the Broker URL fixture ([#15544](https://github.com/QwenLM/qwen-code/pull/15544)) by @wenshao
|
||||
- test(integration): widen the ready-prompt budget on GitHub-hosted macOS x64 ([#15692](https://github.com/QwenLM/qwen-code/pull/15692)) by @yiliang114
|
||||
- chore(goal): remove an unused evidence helper and stale /goal hook comments ([#15693](https://github.com/QwenLM/qwen-code/pull/15693)) by @qqqys
|
||||
- test(web-shell): raise the 200-record history-viewport smoke budget to 300s ([#15656](https://github.com/QwenLM/qwen-code/pull/15656)) by @yiliang114
|
||||
- refactor(managed-agent): close the scanner row-pool residues (#15527) ([#15632](https://github.com/QwenLM/qwen-code/pull/15632)) by @yiliang114
|
||||
- test(managed-agent): initialize the snapshot pool connection synchronously ([#15715](https://github.com/QwenLM/qwen-code/pull/15715)) by @doudouOUC
|
||||
- test(cli): close the deferred review Suggestions from #15625 ([#15710](https://github.com/QwenLM/qwen-code/pull/15710)) by @wenshao
|
||||
|
||||
### New Contributors
|
||||
|
||||
- @sxl-lucky7 made their first contribution in [#15452](https://github.com/QwenLM/qwen-code/pull/15452)
|
||||
- @EthDing made their first contribution in [#8549](https://github.com/QwenLM/qwen-code/pull/8549)
|
||||
- @Aling33 made their first contribution in [#14910](https://github.com/QwenLM/qwen-code/pull/14910)
|
||||
|
||||
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.26.1...v0.26.2
|
||||
|
||||
## [0.26.1](https://github.com/QwenLM/qwen-code/releases/tag/v0.26.1) - 2026-10-24
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -59,11 +59,11 @@ bin/qwen-managed-agent-admin csi-protection \
|
||||
/absolute/path/registration.json resident-protection resident-binding
|
||||
```
|
||||
|
||||
From a module build, run the same entry from the executable server jar:
|
||||
From a module build, run the same entry from the repository root:
|
||||
|
||||
```bash
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.workspace.runtime.WorkspaceCsiProtectionMain \
|
||||
-cp target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.maintenance.WorkspaceCsiProtectionMain \
|
||||
-cp packages/managed-agent-server/server/target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
org.springframework.boot.loader.launch.PropertiesLauncher \
|
||||
/absolute/path/registration.json resident-protection resident-binding
|
||||
```
|
||||
@@ -121,11 +121,11 @@ bin/qwen-managed-agent-admin resident-retirement \
|
||||
--spring.config.additional-location=file:/absolute/path/application.yaml
|
||||
```
|
||||
|
||||
From a module build, use the executable server jar and the same arguments:
|
||||
From a module build, run from the repository root with the same arguments:
|
||||
|
||||
```bash
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.persistence.ResidentWorkspaceRetirementMain \
|
||||
-cp target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.maintenance.ResidentWorkspaceRetirementMain \
|
||||
-cp packages/managed-agent-server/server/target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
org.springframework.boot.loader.launch.PropertiesLauncher \
|
||||
inspect /absolute/path/retirement.json \
|
||||
--spring.config.additional-location=file:/absolute/path/application.yaml
|
||||
|
||||
@@ -46,11 +46,11 @@ bin/qwen-managed-agent-admin csi-protection \
|
||||
/absolute/path/registration.json resident-protection resident-binding
|
||||
```
|
||||
|
||||
在模块构建中,从可执行的 server jar 运行同一个入口:
|
||||
使用模块构建产物时,在仓库根目录运行同一个入口:
|
||||
|
||||
```bash
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.workspace.runtime.WorkspaceCsiProtectionMain \
|
||||
-cp target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.maintenance.WorkspaceCsiProtectionMain \
|
||||
-cp packages/managed-agent-server/server/target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
org.springframework.boot.loader.launch.PropertiesLauncher \
|
||||
/absolute/path/registration.json resident-protection resident-binding
|
||||
```
|
||||
@@ -103,11 +103,11 @@ bin/qwen-managed-agent-admin resident-retirement \
|
||||
--spring.config.additional-location=file:/absolute/path/application.yaml
|
||||
```
|
||||
|
||||
在模块构建中,使用可执行的 server jar 和相同的参数:
|
||||
使用模块构建产物时,在仓库根目录运行,并使用相同的参数:
|
||||
|
||||
```bash
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.persistence.ResidentWorkspaceRetirementMain \
|
||||
-cp target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
java -Dloader.main=com.alibaba.qwen.code.managedagent.maintenance.ResidentWorkspaceRetirementMain \
|
||||
-cp packages/managed-agent-server/server/target/qwen-managed-agent-server-<version>-exec.jar \
|
||||
org.springframework.boot.loader.launch.PropertiesLauncher \
|
||||
inspect /absolute/path/retirement.json \
|
||||
--spring.config.additional-location=file:/absolute/path/application.yaml
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
# Managed Agent on Kubernetes
|
||||
|
||||
[English](README.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||
This experimental Helm chart installs a signed Managed Agent API, a private
|
||||
Hosted Harness in the same Pod, and MySQL 8.4 with a persistent volume. One Helm
|
||||
command installs the stack after you provide images and model configuration.
|
||||
The default supports text Sessions. Optional settings add trusted single-node
|
||||
Workspace files and foreground Shell, and a Managed WebShell gateway. K2 CSI
|
||||
provisioning remains a separate deployment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.28 or newer, Helm 3.18 or newer, kubectl, and a namespace in which
|
||||
you may create workloads, Services, ConfigMaps, Secrets and PVCs.
|
||||
- A default StorageClass supporting ReadWriteOnce, or an explicit
|
||||
`mysql.storage.className`. The default database volume requests 10 GiB.
|
||||
- A container builder and registry reachable from the cluster. Both application
|
||||
images must come from the same reviewed source revision and match the nodes'
|
||||
CPU architecture. Publicly published Managed Agent images are not assumed.
|
||||
- An OpenAI-compatible model endpoint, model ID and API key.
|
||||
|
||||
The default application Pod requests 500m CPU and 1 GiB memory in total; MySQL requests
|
||||
another 250m CPU and 512 MiB. Resource requests and limits are configurable in
|
||||
[values.yaml](chart/values.yaml). The application requires no Kubernetes API
|
||||
permissions and does not mount a service-account token.
|
||||
|
||||
## Build the images
|
||||
|
||||
Run from the repository root. Replace the registry and select the platform of
|
||||
your cluster; the example builds for Linux amd64:
|
||||
|
||||
```bash
|
||||
QWEN_K8S_REV=$(git rev-parse HEAD)
|
||||
QWEN_K8S_REGISTRY=registry.example.com/team
|
||||
docker buildx build --platform linux/amd64 --push \
|
||||
-f packages/managed-agent-server/Dockerfile \
|
||||
-t "$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" .
|
||||
docker buildx build --platform linux/amd64 --push \
|
||||
-f Dockerfile \
|
||||
-t "$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" .
|
||||
```
|
||||
|
||||
Prepare a deployment values file outside the checkout. Use the actual image
|
||||
references above, preferably immutable registry digests:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
image: registry.example.com/team/managed-agent@sha256:<server-digest>
|
||||
harness:
|
||||
image: registry.example.com/team/qwen-cli@sha256:<cli-digest>
|
||||
model:
|
||||
id: <provider-model-id>
|
||||
baseUrl: https://provider.example/v1
|
||||
```
|
||||
|
||||
For a private registry, add `imagePullSecrets: [{name: registry-credentials}]`
|
||||
and create that registry Secret in the deployment namespace before installation.
|
||||
Use the same source revision for both images on every upgrade.
|
||||
|
||||
For LOCAL Workspace tools, build a third image from those exact two images. It
|
||||
combines the Java JAR/JRE with the CLI image's Node.js, bundled worker and Linux
|
||||
filesystem tools. Use matching CPU architectures for all three:
|
||||
|
||||
```bash
|
||||
docker buildx build --platform linux/amd64 --push \
|
||||
-f deploy/kubernetes/runtime.Dockerfile \
|
||||
--build-arg SERVER_IMAGE="$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" \
|
||||
--build-arg HARNESS_IMAGE="$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" \
|
||||
-t "$QWEN_K8S_REGISTRY/managed-agent-runtime:$QWEN_K8S_REV" .
|
||||
```
|
||||
|
||||
The runtime image checks `java`, `node`, the bundled CLI entry and native `stat`
|
||||
during its build. `server.image` selects this combined image when tools are
|
||||
enabled. The default text-only mode continues to use the smaller Java image.
|
||||
WebShell uses the matching CLI image, including its built static assets and
|
||||
`qwen serve --managed-web-config` command.
|
||||
|
||||
## Install
|
||||
|
||||
Store the model API key in a private file, without shell quotes, and restrict its
|
||||
permissions to 0600. Then run from the repository root:
|
||||
|
||||
```bash
|
||||
helm upgrade --install managed-agent deploy/kubernetes/chart \
|
||||
--namespace qwen-managed-agent --create-namespace \
|
||||
--values /private/managed-agent-values.yaml \
|
||||
--set-file model.apiKey=/private/model-api-key \
|
||||
--wait --timeout 15m
|
||||
```
|
||||
|
||||
The chart creates independent random signing, Session Store, Harness and database
|
||||
credentials on the first install. Their Secret is retained on uninstall and
|
||||
reused on subsequent installs and upgrades. Enabling an optional component adds
|
||||
only its missing keys; disabling it retains every existing key. An upgrade with a missing managed
|
||||
Secret fails, as does a reinstall with a retained database PVC but no Secret;
|
||||
restore the original Secret instead of generating new database
|
||||
passwords or journal keys. On an existing release, `model.apiKey` is ignored:
|
||||
rotate the Secret's `model-api-key` explicitly and restart the application Pod.
|
||||
|
||||
Helm stores managed Secret data in its release metadata. To manage credentials
|
||||
separately, set `credentials.existingSecret` and omit `model.apiKey`. The Secret
|
||||
must already exist in the release namespace and contain all of these keys:
|
||||
|
||||
| Key | Value |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth-signing-key` | At least 32 UTF-8 bytes |
|
||||
| `session-store-binding-key` | Independent key, at least 32 UTF-8 bytes |
|
||||
| `harness-token` | Private bearer token shared by Java and Harness |
|
||||
| `capability-digest` | `sha256:` followed by 64 lowercase hexadecimal digits |
|
||||
| `mysql-password` | Application database password |
|
||||
| `mysql-root-password` | MySQL initialization root password |
|
||||
| `model-api-key` | Provider API key |
|
||||
| `runtime-broker-token` | Required with `runtime.enabled`; private Broker bearer token |
|
||||
| `runtime-credential-key` | Required with `runtime.enabled`; base64 encoding of 32 random bytes |
|
||||
| `web-token` | Required with `web.enabled`; independent browser bearer token of at least 32 bytes, different from `auth-signing-key` |
|
||||
|
||||
The Runtime credential key ID is fixed to `kubernetes-v1`; preserve that key for
|
||||
every retained binding. When connected to the cluster, Helm validates an existing
|
||||
external Secret's required keys. Offline `helm template` cannot inspect that
|
||||
Secret; Kubernetes still requires every referenced key before starting a
|
||||
container. Never replace a missing credential on a retained deployment with a
|
||||
new random value.
|
||||
If a retained Runtime PVC exists but its Runtime keys are missing, enabling the
|
||||
managed Runtime also refuses generation and requires the original Secret.
|
||||
|
||||
Changing database Secret values alone does not change MySQL accounts in an
|
||||
existing volume. Coordinate database password rotation separately. MySQL's
|
||||
initialization grants the application user its database privileges and SELECT on
|
||||
`performance_schema.user_variables_by_thread`, required by Flyway's boot probe.
|
||||
|
||||
Only the signed public API is exposed on the ClusterIP Service's port 8080.
|
||||
Harness port 4170 and the Session Store listener on port 8081 bind to loopback.
|
||||
There is no unauthenticated public bind, LoadBalancer or Ingress. Remote clients
|
||||
use a separately configured TLS ingress or trusted gateway while retaining API
|
||||
signatures; tenant and actor headers alone do not authenticate a caller.
|
||||
|
||||
## Enable trusted single-node Workspace tools
|
||||
|
||||
This option runs the current LOCAL process Runtime on one explicitly trusted
|
||||
node. The node must provide its real, nonempty `/etc/machine-id`, Linux process
|
||||
and namespace identity, and persistent storage with reliable directory birth
|
||||
times. The chart mounts that machine ID read-only into the server. It does not
|
||||
manufacture host identity, request privileged containers, or delegate cgroups.
|
||||
Clusters whose policy rejects this hostPath must keep this option disabled.
|
||||
|
||||
Add these values, using the combined image above and the exact Kubernetes Node
|
||||
name from your approved execution host:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
image: registry.example.com/team/managed-agent-runtime@sha256:<runtime-digest>
|
||||
runtime:
|
||||
enabled: true
|
||||
nodeName: <trusted-node-name>
|
||||
storage:
|
||||
workspaceSize: 10Gi
|
||||
stateSize: 1Gi
|
||||
className: <qualified-storage-class>
|
||||
```
|
||||
|
||||
Node affinity pins scheduling to that exact node while allowing the scheduler to
|
||||
bind WaitForFirstConsumer volumes. The chart retains two PVCs: `/workspaces` for
|
||||
created Workspaces, and `/runtime-data` for private state and Runtime home. The
|
||||
legacy cwd remains `/workspace`. The launcher creates owner-private state and
|
||||
home child directories, restores their 0700 directory and 0600 file permissions,
|
||||
and refuses symlinks, unsupported entries and different owners. Kubelet uses
|
||||
`fsGroupChangePolicy: OnRootMismatch` to avoid widening retained records on every
|
||||
mount. SQL, both PVCs, the credential Secret, node identity and original file
|
||||
identities form one deployment; copying PVC contents to another host is not
|
||||
takeover authority.
|
||||
|
||||
After installation, send a signed
|
||||
`POST /api/agent/web-shell/v1/workspaces/create` with
|
||||
`{"displayName":"Project","idempotencyKey":"<unique-key>"}`. The existing API
|
||||
creates a directory, verifies and registers its physical storage, and grants
|
||||
OWNER to the authenticated tenant/actor. Use the returned `workspaceId` in a
|
||||
Session creation request's `workspace.workspace_id`; do not manually populate
|
||||
registration or ownership rows. The WebShell option below offers this same flow.
|
||||
|
||||
This mode enables files, foreground Shell, durable Session isolation and verified
|
||||
Workspace recovery with `default` asking approval. Under that policy, Shell commands require an
|
||||
approval response; child agents and tool publication remain off. Background
|
||||
Shell, Monitor, command Hooks and arbitrary escaped processes are outside this
|
||||
mode's acceptance. The tools execute with the server account's filesystem
|
||||
permissions and can reach paths outside the Workspace; use trusted workloads.
|
||||
No object-storage account is needed for the foreground Shell receipt path.
|
||||
|
||||
Close every tool Session and confirm physical close, sealed journals and clear
|
||||
storage/Runtime holders before any planned Pod replacement or Helm upgrade.
|
||||
Keep those checks as an explicit operational gate; the chart does not drain
|
||||
Sessions automatically. A new Pod has different PID/time namespaces and cannot
|
||||
adopt the old LOCAL worker merely because its node and PVC are unchanged. After
|
||||
a clean rollout, create a new Session on the retained Workspace. An unexpected
|
||||
worker or Pod loss must retain its uncertain effects and admission fences;
|
||||
neither retries nor removing SQL rows authorizes replay or fabricated cleanup.
|
||||
|
||||
## Enable Managed WebShell
|
||||
|
||||
The optional gateway serves only the Managed UI and signs its Java requests as
|
||||
one fixed trusted tenant/actor. It does not create a multi-user authentication
|
||||
system. Enable it independently, or together with LOCAL tools:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
enabled: true
|
||||
tenantId: <trusted-tenant-id>
|
||||
actorId: <trusted-actor-id>
|
||||
```
|
||||
|
||||
An empty `web.image` reuses `harness.image`. The gateway has its own home and
|
||||
temporary directory, receives only the browser token and a read-only signing-key
|
||||
file, and reads no model API key or Runtime/Workspace volume. Its configuration
|
||||
contains the loopback Java URL, fixed tenant/actor and signing-key file path;
|
||||
browser-supplied identity headers do not select the caller. Ordinary daemon
|
||||
Sessions, filesystem, Shell, settings, MCP, ACP and WebSocket routes are closed.
|
||||
|
||||
Forward the browser port:
|
||||
|
||||
```bash
|
||||
kubectl -n qwen-managed-agent port-forward service/managed-agent 4171:4171
|
||||
```
|
||||
|
||||
Open [the local WebShell](http://127.0.0.1:4171/) and authenticate with the
|
||||
Secret's independent `web-token`. Keep the signing key server-side. When LOCAL
|
||||
tools are enabled, create/select a Workspace and complete approvals through the
|
||||
Managed UI. For an external TLS ingress, configure the exact browser origin in
|
||||
`web.allowOrigins`, for example `[https://agent.example.com]`; the chart itself
|
||||
does not create an ingress.
|
||||
|
||||
## Verify a Session
|
||||
|
||||
Forward the API in one terminal:
|
||||
|
||||
```bash
|
||||
kubectl -n qwen-managed-agent port-forward service/managed-agent 8080:8080
|
||||
```
|
||||
|
||||
In another terminal, export the signing key to a private temporary file and run
|
||||
the signed smoke client. With an operator-managed Secret, replace the Secret
|
||||
name below:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
QWEN_K8S_KEY_FILE=$(mktemp)
|
||||
kubectl -n qwen-managed-agent get secret managed-agent-keys \
|
||||
-o jsonpath='{.data.auth-signing-key}' | openssl base64 -d -A > "$QWEN_K8S_KEY_FILE"
|
||||
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE"
|
||||
```
|
||||
|
||||
The smoke requires Node.js 22 or newer. It checks rejection of unsigned requests,
|
||||
creates a Session, submits a unique text marker, waits for a completed model Turn,
|
||||
verifies assistant history and closes the Session. Success prints Session and
|
||||
Turn IDs as JSON. It makes a real provider call using the configured account.
|
||||
On failure it reports the Session ID and leaves that Session for inspection; it
|
||||
does not replay an uncertain input. Logs can be read with
|
||||
`kubectl -n qwen-managed-agent logs deployment/managed-agent -c server` or
|
||||
`-c harness`. The default timeout is 120 seconds; `--timeout-ms` overrides it.
|
||||
|
||||
To check idle persistence, first run the smoke with `--keep-session`. Record its
|
||||
Session ID, restart the application, then resume the smoke using that ID:
|
||||
|
||||
```bash
|
||||
kubectl -n qwen-managed-agent rollout restart deployment/managed-agent
|
||||
kubectl -n qwen-managed-agent rollout status deployment/managed-agent --timeout=10m
|
||||
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE" --session-id <session-id>
|
||||
```
|
||||
|
||||
After a restart, a new Turn may wait for the previous 60-second writer lease and
|
||||
automatic retries before it can run. Increase `--timeout-ms` if the model also
|
||||
needs a longer response window. This verifies
|
||||
retained history and a new Turn after an idle restart. It does not verify
|
||||
arbitrary interrupted-Turn recovery, multi-replica HA or CSI ownership recovery.
|
||||
After the final smoke, remove the temporary key with `rm "$QWEN_K8S_KEY_FILE"`.
|
||||
|
||||
## Upgrade and uninstall
|
||||
|
||||
Stop new admissions, settle active work, and back up MySQL plus the complete
|
||||
credential Secret before changing versions. With LOCAL tools, first close every
|
||||
tool Session and confirm all physical holders are clear as described above.
|
||||
Retain the namespace, release name,
|
||||
MySQL and optional Runtime/Workspace PVCs and credentials. Repeat the installation command with matching
|
||||
new images and run the Session smoke before restoring traffic. The application
|
||||
uses one replica and Recreate, so upgrades have downtime. Do not scale it to
|
||||
multiple replicas.
|
||||
|
||||
Do not use `--atomic` or automatically roll back a schema-changing upgrade.
|
||||
Flyway migrations run before readiness; an older binary is not guaranteed to
|
||||
accept the new schema. Fix forward, or restore the matching database and
|
||||
application backup before accepting writes. Current pre-release development
|
||||
builds may change the canonical V1 baseline in place; those incompatible catalogs
|
||||
require a fresh database, not an in-place Helm upgrade. Preserve historical data
|
||||
with its matching application version.
|
||||
|
||||
```bash
|
||||
helm uninstall managed-agent --namespace qwen-managed-agent
|
||||
```
|
||||
|
||||
Uninstall retains the managed Secret, StatefulSet PVC and optional Runtime/Workspace PVCs. Delete none while
|
||||
history is needed; permanent data removal is a separate explicit operation.
|
||||
Changing the namespace or release name creates a different deployment.
|
||||
|
||||
## Validation boundary
|
||||
|
||||
Run `helm lint` with deployment inputs and
|
||||
`node --test deploy/kubernetes/*.test.mjs` from the repository root.
|
||||
`HELM_BIN=/absolute/path/to/helm` selects a Helm executable outside PATH.
|
||||
The tests render the chart and exercise credential reuse with a controlled
|
||||
Kubernetes API peer, plus the signed smoke protocol with a controlled HTTP peer.
|
||||
These are not real-cluster acceptance.
|
||||
|
||||
Record image builds, installation, real MySQL, provider calls and restart results
|
||||
for each target cluster. ACK, node failure, CSI, physical retirement and production
|
||||
acceptance remain separate gates. The optional trusted LOCAL mode is separate
|
||||
from [K2 resident deployment](../k2-resident/README.md), which requires qualified
|
||||
CSI identities and does not support Shell. The chart does not enable K2. Design:
|
||||
[English](../../docs/design/2026-10-24-managed-agent-kubernetes.md) |
|
||||
[简体中文](../../docs/design/2026-10-24-managed-agent-kubernetes.zh-CN.md);
|
||||
tools and WebShell:
|
||||
[English](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.md) |
|
||||
[简体中文](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.zh-CN.md).
|
||||
@@ -0,0 +1,280 @@
|
||||
# 在 Kubernetes 部署 Managed Agent
|
||||
|
||||
[English](README.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||
这个实验性 Helm chart 安装签名认证的 Managed Agent API、同 Pod 的私有 Hosted
|
||||
Harness,以及使用持久卷的 MySQL 8.4。提供镜像和模型配置后,一条 Helm 命令即可安装
|
||||
整套服务。默认支持文本 Session;可选配置增加可信单节点 Workspace 文件、前台
|
||||
Shell 和 Managed WebShell 网关。K2 CSI provisioning 仍是独立部署。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Kubernetes 1.28 或更新版本、Helm 3.18 或更新版本、kubectl,以及可创建 workload、
|
||||
Service、ConfigMap、Secret 和 PVC 的 namespace。
|
||||
- 支持 ReadWriteOnce 的默认 StorageClass,或显式指定
|
||||
`mysql.storage.className`。数据库卷默认申请 10 GiB。
|
||||
- 容器构建工具和集群可访问的镜像仓库。两个应用镜像必须来自同一经过评审的源码
|
||||
revision,并匹配节点 CPU 架构。不假设已有公开发布的 Managed Agent 镜像。
|
||||
- OpenAI 兼容模型 endpoint、模型 ID 和 API key。
|
||||
|
||||
默认应用 Pod 总计申请 500m CPU 和 1 GiB 内存;MySQL 额外申请 250m CPU 和 512 MiB
|
||||
内存。资源 requests 和 limits 可在 [values.yaml](chart/values.yaml) 配置。
|
||||
应用无需 Kubernetes API 权限,不挂载 service-account token。
|
||||
|
||||
## 构建镜像
|
||||
|
||||
在仓库根目录执行。替换镜像仓库并选择集群平台;示例构建 Linux amd64 镜像:
|
||||
|
||||
```bash
|
||||
QWEN_K8S_REV=$(git rev-parse HEAD)
|
||||
QWEN_K8S_REGISTRY=registry.example.com/team
|
||||
docker buildx build --platform linux/amd64 --push \
|
||||
-f packages/managed-agent-server/Dockerfile \
|
||||
-t "$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" .
|
||||
docker buildx build --platform linux/amd64 --push \
|
||||
-f Dockerfile \
|
||||
-t "$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" .
|
||||
```
|
||||
|
||||
在 checkout 外准备部署 values 文件。使用上面的真实镜像地址,优先采用不可变镜像
|
||||
摘要:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
image: registry.example.com/team/managed-agent@sha256:<server-digest>
|
||||
harness:
|
||||
image: registry.example.com/team/qwen-cli@sha256:<cli-digest>
|
||||
model:
|
||||
id: <provider-model-id>
|
||||
baseUrl: https://provider.example/v1
|
||||
```
|
||||
|
||||
私有仓库增加 `imagePullSecrets: [{name: registry-credentials}]`,并在安装前在部署
|
||||
namespace 创建对应仓库 Secret。每次升级的两个镜像都应来自同一源码 revision。
|
||||
|
||||
启用 LOCAL Workspace 工具时,从上述精确的两个镜像构建第三个镜像。它将 Java
|
||||
JAR/JRE 与 CLI 镜像中的 Node.js、bundled worker 和 Linux 文件系统工具组合。
|
||||
三个镜像的 CPU 架构必须相同:
|
||||
|
||||
```bash
|
||||
docker buildx build --platform linux/amd64 --push \
|
||||
-f deploy/kubernetes/runtime.Dockerfile \
|
||||
--build-arg SERVER_IMAGE="$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" \
|
||||
--build-arg HARNESS_IMAGE="$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" \
|
||||
-t "$QWEN_K8S_REGISTRY/managed-agent-runtime:$QWEN_K8S_REV" .
|
||||
```
|
||||
|
||||
Runtime 镜像在构建时检查 `java`、`node`、bundled CLI 入口和原生 `stat`。
|
||||
启用工具时,将 `server.image` 指向此组合镜像。默认文本模式继续使用较小的 Java
|
||||
镜像。WebShell 使用匹配的 CLI 镜像,其中需包含构建后的静态资源和
|
||||
`qwen serve --managed-web-config` 命令。
|
||||
|
||||
## 安装
|
||||
|
||||
将模型 API key 写入私有文件,不包含 shell 引号,权限限制为 0600。然后在仓库根目录
|
||||
执行:
|
||||
|
||||
```bash
|
||||
helm upgrade --install managed-agent deploy/kubernetes/chart \
|
||||
--namespace qwen-managed-agent --create-namespace \
|
||||
--values /private/managed-agent-values.yaml \
|
||||
--set-file model.apiKey=/private/model-api-key \
|
||||
--wait --timeout 15m
|
||||
```
|
||||
|
||||
首次安装时,chart 生成相互独立的签名、Session Store、Harness 和数据库随机凭据。
|
||||
卸载时保留对应 Secret,后续安装和升级复用它。开启可选组件时仅补齐其缺失的键,
|
||||
关闭组件仍保留已有全部键。升级时缺少受管 Secret 会失败;请恢复
|
||||
原 Secret;保留数据库 PVC 却缺少 Secret 时,重新安装也会拒绝。不要重新生成数据库
|
||||
密码或 journal 密钥。已有部署忽略 `model.apiKey`:
|
||||
更新模型凭据需显式轮换 Secret 的 `model-api-key`,然后重启应用 Pod。
|
||||
|
||||
Helm 会将受管 Secret 内容保存到 release 元数据。若要独立管理凭据,设置
|
||||
`credentials.existingSecret` 并省略 `model.apiKey`。该 Secret 必须已存在于 release
|
||||
namespace,包含以下全部键:
|
||||
|
||||
| 键 | 值 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `auth-signing-key` | 至少 32 个 UTF-8 字节 |
|
||||
| `session-store-binding-key` | 独立密钥,至少 32 个 UTF-8 字节 |
|
||||
| `harness-token` | Java 与 Harness 共享的私有 bearer token |
|
||||
| `capability-digest` | `sha256:` 后跟 64 位小写十六进制字符 |
|
||||
| `mysql-password` | 应用数据库密码 |
|
||||
| `mysql-root-password` | MySQL 初始化 root 密码 |
|
||||
| `model-api-key` | Provider API key |
|
||||
| `runtime-broker-token` | `runtime.enabled` 时必需;私有 Broker bearer token |
|
||||
| `runtime-credential-key` | `runtime.enabled` 时必需;32 个随机字节的 base64 编码 |
|
||||
| `web-token` | `web.enabled` 时必需;至少 32 字节的独立浏览器 bearer token,不能等于 `auth-signing-key` |
|
||||
|
||||
Runtime credential key ID 固定为 `kubernetes-v1`;存在保留的 binding 时必须保留此
|
||||
密钥。连接集群时,Helm 会检查已有自管 Secret 的必需键;离线 `helm template` 无法
|
||||
读取它,Kubernetes 仍会在容器启动前要求所有引用的键存在。已有部署缺少凭据时,
|
||||
不能用新随机值替换。
|
||||
保留 Runtime PVC 却缺少 Runtime 密钥时,开启受管 Runtime 也会拒绝生成新值,要求
|
||||
恢复原 Secret。
|
||||
|
||||
仅更改数据库 Secret 不会更改已有卷中的 MySQL 账号,数据库密码轮换需单独协调。
|
||||
MySQL 初始化时授予应用用户数据库权限,以及 Flyway 启动探针所需的
|
||||
`performance_schema.user_variables_by_thread` SELECT 权限。
|
||||
|
||||
ClusterIP Service 仅在 8080 暴露签名认证的公开 API。Harness 4170 和 Session Store
|
||||
8081 都绑定 loopback。不启用无认证公开监听、LoadBalancer 或 Ingress。远程客户端
|
||||
使用独立配置的 TLS ingress 或可信网关,并保留 API 签名;tenant 和 actor header
|
||||
本身不能认证调用者。
|
||||
|
||||
## 启用可信单节点 Workspace 工具
|
||||
|
||||
此选项在一个显式信任的节点上运行现有 LOCAL 进程 Runtime。节点必须提供真实且非空
|
||||
的 `/etc/machine-id`、Linux 进程和 namespace 身份,以及支持可靠目录 birth time
|
||||
的持久存储。chart 将节点 machine ID 只读挂入服务端,不伪造主机身份,不申请
|
||||
privileged 容器,也不委派 cgroup。集群策略拒绝此 hostPath 时,请保持关闭。
|
||||
|
||||
增加以下 values,使用上述组合镜像,并填写已批准执行主机的精确 Kubernetes Node
|
||||
名称:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
image: registry.example.com/team/managed-agent-runtime@sha256:<runtime-digest>
|
||||
runtime:
|
||||
enabled: true
|
||||
nodeName: <trusted-node-name>
|
||||
storage:
|
||||
workspaceSize: 10Gi
|
||||
stateSize: 1Gi
|
||||
className: <qualified-storage-class>
|
||||
```
|
||||
|
||||
Node affinity 将调度固定到该节点,同时保留 scheduler 绑定 WaitForFirstConsumer
|
||||
卷的流程。chart 保留两个 PVC:`/workspaces` 保存创建的 Workspace,
|
||||
`/runtime-data` 保存私有 state 和 Runtime home;旧式 cwd 仍为 `/workspace`。
|
||||
启动器创建 owner 私有的 state/home 子目录,将目录和文件分别恢复为 0700/0600,
|
||||
拒绝符号链接、非普通条目和不同 owner。Kubelet 使用
|
||||
`fsGroupChangePolicy: OnRootMismatch`,避免每次挂载都扩宽已有记录权限。SQL、
|
||||
两个 PVC、凭据 Secret、节点身份和原文件身份共同构成一个部署;复制 PVC 内容到
|
||||
其他主机不能授权接管。
|
||||
|
||||
安装后,以签名请求调用 `POST /api/agent/web-shell/v1/workspaces/create`,body 为
|
||||
`{"displayName":"Project","idempotencyKey":"<unique-key>"}`。现有 API 创建目录、
|
||||
验证并注册物理存储,再授予已认证 tenant/actor OWNER 权限。将返回的 `workspaceId`
|
||||
用于 Session 创建请求的 `workspace.workspace_id`,不要手工写入注册或权限数据行。
|
||||
下方 WebShell 选项提供同一流程。
|
||||
|
||||
该模式启用文件、前台 Shell、持久 Session 隔离和 verified Workspace recovery,
|
||||
使用 `default` 询问式审批。在该策略下,Shell 命令需要审批响应;child agents 和 tool publication
|
||||
保持关闭。后台 Shell、Monitor、command Hooks 和任意逃逸进程不在该模式的验收范围。
|
||||
工具使用服务端账号的文件系统权限,可访问 Workspace 之外的路径,请用于可信工作负载。
|
||||
前台 Shell receipt 路径不需要对象存储账号。
|
||||
|
||||
计划替换 Pod 或 Helm 升级前,必须关闭所有工具 Session,确认物理 close、journal
|
||||
sealed,以及 storage/Runtime holder 已清空。请保留这一显式运维门槛;chart 不自动
|
||||
drain Session。新 Pod 的 PID/time namespace 已改变,不能因节点和 PVC 不变就接管旧
|
||||
LOCAL worker。干净升级后,应对保留的 Workspace 创建新 Session。意外 worker/Pod
|
||||
丢失必须保留原来的不确定 effect 和准入 fence;重试或删除 SQL 行都不能授权重放或
|
||||
伪造清理结果。
|
||||
|
||||
## 启用 Managed WebShell
|
||||
|
||||
可选网关只提供 Managed UI,并以一个固定可信 tenant/actor 对 Java 请求签名,不构成
|
||||
多用户认证系统。可以独立开启,也可以与 LOCAL 工具一起开启:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
enabled: true
|
||||
tenantId: <trusted-tenant-id>
|
||||
actorId: <trusted-actor-id>
|
||||
```
|
||||
|
||||
`web.image` 留空时复用 `harness.image`。网关使用独立 home 和临时目录,仅接收浏览器
|
||||
token 和只读签名密钥文件,不读取模型 API key 或 Runtime/Workspace 卷。其配置只包含
|
||||
loopback Java URL、固定 tenant/actor 和签名密钥文件路径;浏览器身份 header 不决定
|
||||
调用者。普通 daemon Session、文件系统、Shell、设置、MCP、ACP 和 WebSocket 路由
|
||||
均关闭。
|
||||
|
||||
转发浏览器端口:
|
||||
|
||||
```bash
|
||||
kubectl -n qwen-managed-agent port-forward service/managed-agent 4171:4171
|
||||
```
|
||||
|
||||
打开[本地 WebShell](http://127.0.0.1:4171/),使用 Secret 中独立的 `web-token` 完成
|
||||
认证。签名密钥留在服务端。启用 LOCAL 工具后,可通过 Managed UI 创建/选择
|
||||
Workspace 和完成审批。使用外部 TLS ingress 时,在 `web.allowOrigins` 配置精确的
|
||||
浏览器 origin,例如 `[https://agent.example.com]`;chart 本身不创建 ingress。
|
||||
|
||||
## 验证 Session
|
||||
|
||||
在一个终端转发 API:
|
||||
|
||||
```bash
|
||||
kubectl -n qwen-managed-agent port-forward service/managed-agent 8080:8080
|
||||
```
|
||||
|
||||
在另一个终端将签名密钥导出到私有临时文件,然后运行签名 smoke 客户端。
|
||||
使用自管 Secret 时替换下方 Secret 名:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
QWEN_K8S_KEY_FILE=$(mktemp)
|
||||
kubectl -n qwen-managed-agent get secret managed-agent-keys \
|
||||
-o jsonpath='{.data.auth-signing-key}' | openssl base64 -d -A > "$QWEN_K8S_KEY_FILE"
|
||||
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE"
|
||||
```
|
||||
|
||||
smoke 需要 Node.js 22 或更新版本。它验证无签名请求被拒绝、创建 Session、提交唯一
|
||||
文本标记、等待模型 Turn 完成、核对 assistant 历史并关闭 Session。成功时以 JSON
|
||||
输出 Session 和 Turn ID。它会使用已配置账号真实调用 provider。失败时报告 Session
|
||||
ID,保留 Session 供检查,不重放结果不确定的输入。可使用
|
||||
`kubectl -n qwen-managed-agent logs deployment/managed-agent -c server` 或
|
||||
`-c harness` 查看日志。默认超时 120 秒,可用 `--timeout-ms` 调整。
|
||||
|
||||
验证空闲持久化时,第一次为 smoke 增加 `--keep-session`。记录输出的 Session ID,
|
||||
重启应用,再使用该 ID 继续 smoke:
|
||||
|
||||
```bash
|
||||
kubectl -n qwen-managed-agent rollout restart deployment/managed-agent
|
||||
kubectl -n qwen-managed-agent rollout status deployment/managed-agent --timeout=10m
|
||||
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE" --session-id <session-id>
|
||||
```
|
||||
|
||||
重启后的新 Turn 可能需要等待旧的 60 秒 writer lease 和自动重试后才能执行;模型
|
||||
响应也较慢时,请增加 `--timeout-ms`。此流程验证空闲重启后保留历史并完成新 Turn,
|
||||
不代表任意在途 Turn 恢复、多副本 HA 或 CSI ownership 恢复已验收。
|
||||
最后一次 smoke 结束后,使用 `rm "$QWEN_K8S_KEY_FILE"` 删除临时密钥。
|
||||
|
||||
## 升级与卸载
|
||||
|
||||
升级前停止新准入、结算活动工作,并备份 MySQL 和完整凭据 Secret。启用 LOCAL 工具
|
||||
时,先按上述流程关闭全部工具 Session,确认所有物理 holder 已清空。保持 namespace、
|
||||
release 名、MySQL 和可选 Runtime/Workspace PVC,以及凭据不变。使用匹配的新镜像
|
||||
重复安装命令,恢复流量前执行
|
||||
Session smoke。应用固定单副本并使用 Recreate,升级存在停机时间,请勿扩为多副本。
|
||||
|
||||
不要使用 `--atomic`,也不要自动回滚改变 schema 的升级。Flyway 在 readiness 前执行
|
||||
migration,旧 binary 不保证接受新 schema。请向前修复,或在接受写入前恢复匹配的
|
||||
数据库和应用备份。当前预发布开发版本可能原地更新 canonical V1 baseline;不兼容的
|
||||
catalog 需要新数据库,不能直接 Helm 升级。需要历史数据时,保留与其匹配的应用版本。
|
||||
|
||||
```bash
|
||||
helm uninstall managed-agent --namespace qwen-managed-agent
|
||||
```
|
||||
|
||||
卸载保留受管 Secret、StatefulSet PVC 和可选 Runtime/Workspace PVC。需要历史时
|
||||
不能删除;永久清理数据是独立的
|
||||
显式操作。修改 namespace 或 release 名会创建另一个部署。
|
||||
|
||||
## 验证边界
|
||||
|
||||
在仓库根目录使用部署输入执行 `helm lint`,并执行
|
||||
`node --test deploy/kubernetes/*.test.mjs`。可通过
|
||||
`HELM_BIN=/absolute/path/to/helm` 指定 PATH 外的 Helm。测试渲染 chart,并通过受控
|
||||
Kubernetes API peer 验证凭据复用,通过受控 HTTP peer 验证签名 smoke 协议。
|
||||
这些测试不等于真实集群验收。
|
||||
|
||||
每个目标集群应独立记录镜像构建、安装、真实 MySQL、provider 调用和重启结果。
|
||||
ACK、节点故障、CSI、物理退役与生产验收仍是独立门槛。可选可信 LOCAL 模式与
|
||||
[K2 常驻部署](../k2-resident/README.zh-CN.md) 独立;后者需要经过资格验证的 CSI
|
||||
身份,且不支持 Shell。chart 不开启 K2。
|
||||
设计:[English](../../docs/design/2026-10-24-managed-agent-kubernetes.md) |
|
||||
[简体中文](../../docs/design/2026-10-24-managed-agent-kubernetes.zh-CN.md);工具与 WebShell:
|
||||
[English](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.md) |
|
||||
[简体中文](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.zh-CN.md)。
|
||||
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { parseAllDocuments } from 'yaml';
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const helm = process.env.HELM_BIN || 'helm';
|
||||
const chart = fileURLToPath(new URL('./chart', import.meta.url));
|
||||
const inputs = [
|
||||
'--namespace',
|
||||
'test-space',
|
||||
'--set',
|
||||
'server.image=server:test',
|
||||
'--set',
|
||||
'harness.image=harness:test',
|
||||
'--set',
|
||||
'model.id=test-model',
|
||||
'--set',
|
||||
'model.baseUrl=https://model.example/v1',
|
||||
'--set',
|
||||
'model.apiKey=fixture-key',
|
||||
];
|
||||
const render = async (args = [], baseInputs = inputs) => {
|
||||
const { stdout } = await execute(helm, [
|
||||
'template',
|
||||
'fixture',
|
||||
chart,
|
||||
...baseInputs,
|
||||
...args,
|
||||
]);
|
||||
return parseAllDocuments(stdout).map((document) => {
|
||||
assert.deepEqual(document.errors, []);
|
||||
return document.toJSON();
|
||||
});
|
||||
};
|
||||
const find = (objects, kind) => objects.find((object) => object.kind === kind);
|
||||
const env = (container) =>
|
||||
Object.fromEntries(container.env.map((item) => [item.name, item]));
|
||||
|
||||
test('renders isolated signed API and private Harness/Store with safe probes', async () => {
|
||||
const objects = await render();
|
||||
const deployment = find(objects, 'Deployment');
|
||||
assert.equal(deployment.spec.replicas, 1);
|
||||
assert.equal(deployment.spec.strategy.type, 'Recreate');
|
||||
const pod = deployment.spec.template.spec;
|
||||
assert.equal(pod.automountServiceAccountToken, false);
|
||||
const [server, harness] = pod.containers;
|
||||
const serverEnv = env(server);
|
||||
const harnessEnv = env(harness);
|
||||
assert.equal(serverEnv.QWEN_MANAGED_AGENT_AUTH_MODE.value, 'signed');
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_ID.value,
|
||||
createHash('sha256')
|
||||
.update(serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_CWD.value)
|
||||
.digest('hex')
|
||||
.slice(0, 16),
|
||||
);
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_INTERNAL_SERVER_ADDRESS.value,
|
||||
'127.0.0.1',
|
||||
);
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL.value,
|
||||
'http://127.0.0.1:8081',
|
||||
);
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED.value,
|
||||
'false',
|
||||
);
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED.value,
|
||||
'false',
|
||||
);
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED.value,
|
||||
'false',
|
||||
);
|
||||
assert.deepEqual(
|
||||
serverEnv.QWEN_MANAGED_AGENT_HARNESS_TOKEN.valueFrom,
|
||||
harnessEnv.QWEN_SERVER_TOKEN.valueFrom,
|
||||
);
|
||||
assert.deepEqual(
|
||||
serverEnv.QWEN_MANAGED_AGENT_CAPABILITY_DIGEST.valueFrom,
|
||||
harnessEnv.QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST.valueFrom,
|
||||
);
|
||||
assert.equal(server.livenessProbe.httpGet.path, '/actuator/health/liveness');
|
||||
assert.equal(
|
||||
server.readinessProbe.httpGet.path,
|
||||
'/actuator/health/readiness',
|
||||
);
|
||||
assert.equal(
|
||||
serverEnv.MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE.value,
|
||||
'readinessState,db',
|
||||
);
|
||||
assert.match(
|
||||
harness.readinessProbe.exec.command[2],
|
||||
/127\.0\.0\.1:4170\/health/,
|
||||
);
|
||||
assert.match(
|
||||
harness.readinessProbe.exec.command[2],
|
||||
/Bearer.*QWEN_SERVER_TOKEN/,
|
||||
);
|
||||
assert.deepEqual(harness.startupProbe.exec, harness.livenessProbe.exec);
|
||||
for (const container of pod.containers) {
|
||||
assert.equal(container.securityContext.runAsNonRoot, true);
|
||||
assert.equal(container.securityContext.readOnlyRootFilesystem, true);
|
||||
assert.equal(container.securityContext.allowPrivilegeEscalation, false);
|
||||
assert.deepEqual(container.securityContext.capabilities.drop, ['ALL']);
|
||||
}
|
||||
const api = objects.find(
|
||||
(object) => object.kind === 'Service' && object.metadata.name === 'fixture',
|
||||
);
|
||||
assert.deepEqual(
|
||||
api.spec.ports.map((port) => port.port),
|
||||
[8080],
|
||||
);
|
||||
assert.deepEqual(api.spec.selector, deployment.spec.selector.matchLabels);
|
||||
});
|
||||
|
||||
test('secrets are distinct, retained and absent from model ConfigMap and argv', async () => {
|
||||
const objects = await render();
|
||||
const secret = find(objects, 'Secret');
|
||||
const decode = (name) => Buffer.from(secret.data[name], 'base64').toString();
|
||||
assert.equal(secret.metadata.annotations['helm.sh/resource-policy'], 'keep');
|
||||
assert(decode('auth-signing-key').length >= 32);
|
||||
assert.notEqual(
|
||||
decode('auth-signing-key'),
|
||||
decode('session-store-binding-key'),
|
||||
);
|
||||
assert.match(decode('capability-digest'), /^sha256:[0-9a-f]{64}$/);
|
||||
assert.equal(decode('model-api-key'), 'fixture-key');
|
||||
const config = find(objects, 'ConfigMap');
|
||||
assert(!JSON.stringify(config).includes('fixture-key'));
|
||||
const settings = JSON.parse(config.data['settings.json']);
|
||||
assert.equal(settings.security.auth.selectedType, 'openai');
|
||||
assert.equal(settings.model.name, settings.modelProviders.openai[0].id);
|
||||
assert.equal(settings.modelProviders.openai[0].envKey, 'OPENAI_API_KEY');
|
||||
const containers = find(objects, 'Deployment').spec.template.spec.containers;
|
||||
for (const container of containers) {
|
||||
assert(!JSON.stringify(container.args ?? []).includes('fixture-key'));
|
||||
}
|
||||
});
|
||||
|
||||
test('bundled MySQL persists data and grants the actual Flyway boot probe', async () => {
|
||||
const objects = await render([
|
||||
'--set',
|
||||
'mysql.storage.size=20Gi',
|
||||
'--set',
|
||||
'mysql.storage.className=fast',
|
||||
'--set',
|
||||
'imagePullSecrets[0].name=private-registry',
|
||||
]);
|
||||
const mysql = find(objects, 'StatefulSet');
|
||||
assert.equal(
|
||||
mysql.spec.volumeClaimTemplates[0].spec.storageClassName,
|
||||
'fast',
|
||||
);
|
||||
assert.equal(
|
||||
mysql.spec.volumeClaimTemplates[0].spec.resources.requests.storage,
|
||||
'20Gi',
|
||||
);
|
||||
assert.match(
|
||||
find(objects, 'ConfigMap').data['mysql-init.sql'],
|
||||
/GRANT SELECT ON performance_schema\.user_variables_by_thread TO 'qwen'@'%'/,
|
||||
);
|
||||
const pod = mysql.spec.template.spec;
|
||||
assert.equal(pod.automountServiceAccountToken, false);
|
||||
assert.match(pod.containers[0].readinessProbe.exec.command[2], /--user=qwen/);
|
||||
assert.match(
|
||||
pod.containers[0].readinessProbe.exec.command[2],
|
||||
/performance_schema\.user_variables_by_thread/,
|
||||
);
|
||||
assert.equal(
|
||||
pod.containers[0].volumeMounts.find((mount) => mount.name === 'data')
|
||||
.subPath,
|
||||
'mysql',
|
||||
);
|
||||
for (const object of objects.filter((item) =>
|
||||
['Deployment', 'StatefulSet'].includes(item.kind),
|
||||
)) {
|
||||
assert.deepEqual(object.spec.template.spec.imagePullSecrets, [
|
||||
{ name: 'private-registry' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('LOCAL tools require a fixed node and persist private state outside Workspaces', async () => {
|
||||
await assert.rejects(
|
||||
render(['--set', 'runtime.enabled=true']),
|
||||
/Set runtime.nodeName/,
|
||||
);
|
||||
const objects = await render([
|
||||
'--set',
|
||||
'runtime.enabled=true',
|
||||
'--set',
|
||||
'runtime.nodeName=trusted-node',
|
||||
'--set',
|
||||
'runtime.storage.className=local',
|
||||
]);
|
||||
const pod = find(objects, 'Deployment').spec.template.spec;
|
||||
assert.equal(pod.nodeName, undefined);
|
||||
assert.deepEqual(
|
||||
pod.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution
|
||||
.nodeSelectorTerms[0].matchFields,
|
||||
[{ key: 'metadata.name', operator: 'In', values: ['trusted-node'] }],
|
||||
);
|
||||
assert.equal(pod.securityContext.fsGroupChangePolicy, 'OnRootMismatch');
|
||||
const [server, harness] = pod.containers;
|
||||
const serverEnv = env(server);
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_ID.value,
|
||||
createHash('sha256')
|
||||
.update(serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_CWD.value)
|
||||
.digest('hex')
|
||||
.slice(0, 16),
|
||||
);
|
||||
for (const key of [
|
||||
'QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED',
|
||||
'QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED',
|
||||
'QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED',
|
||||
'QWEN_MANAGED_AGENT_RUNTIME_VERIFIED_WORKSPACE_RECOVERY_ENABLED',
|
||||
'QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS',
|
||||
]) {
|
||||
assert.equal(serverEnv[key].value, 'true');
|
||||
}
|
||||
for (const key of [
|
||||
'QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY',
|
||||
'QWEN_MANAGED_AGENT_WORKSPACE_CHILD_AGENTS_ENABLED',
|
||||
'QWEN_MANAGED_AGENT_TOOL_PUBLICATION_ENABLED',
|
||||
]) {
|
||||
assert.equal(serverEnv[key].value, 'false');
|
||||
}
|
||||
assert.equal(serverEnv.QWEN_MANAGED_AGENT_APPROVAL_MODE.value, 'default');
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY.value,
|
||||
'/runtime-data/state',
|
||||
);
|
||||
assert.equal(serverEnv.QWEN_HOME.value, '/runtime-data/home');
|
||||
assert.equal(
|
||||
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_CREATION_ROOT.value,
|
||||
'/workspaces',
|
||||
);
|
||||
assert.deepEqual(
|
||||
serverEnv.QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN.valueFrom,
|
||||
env(harness).QWEN_MANAGED_RUNTIME_BROKER_TOKEN.valueFrom,
|
||||
);
|
||||
assert.deepEqual(
|
||||
pod.volumes.find((volume) => volume.name === 'machine-id').hostPath,
|
||||
{ path: '/etc/machine-id', type: 'File' },
|
||||
);
|
||||
assert.equal(
|
||||
server.volumeMounts.find((mount) => mount.name === 'machine-id').readOnly,
|
||||
true,
|
||||
);
|
||||
assert(!harness.volumeMounts.some((mount) => mount.name === 'runtime-data'));
|
||||
const claims = objects.filter(
|
||||
(object) => object.kind === 'PersistentVolumeClaim',
|
||||
);
|
||||
assert.equal(claims.length, 2);
|
||||
for (const claim of claims) {
|
||||
assert.equal(claim.metadata.annotations['helm.sh/resource-policy'], 'keep');
|
||||
assert.equal(claim.spec.storageClassName, 'local');
|
||||
}
|
||||
const key = find(objects, 'Secret').data['runtime-credential-key'];
|
||||
assert.equal(
|
||||
Buffer.from(Buffer.from(key, 'base64').toString(), 'base64').length,
|
||||
32,
|
||||
);
|
||||
});
|
||||
|
||||
test('WebShell keeps its fixed identity and signing key server-side', async () => {
|
||||
await assert.rejects(
|
||||
render(['--set', 'web.enabled=true']),
|
||||
/Set web.tenantId/,
|
||||
);
|
||||
const objects = await render([
|
||||
'--set',
|
||||
'web.enabled=true',
|
||||
'--set',
|
||||
'web.tenantId=tenant-a',
|
||||
'--set',
|
||||
'web.actorId=actor-a',
|
||||
'--set',
|
||||
'web.allowOrigins[0]=https://agent.example',
|
||||
]);
|
||||
const pod = find(objects, 'Deployment').spec.template.spec;
|
||||
const web = pod.containers.find((container) => container.name === 'web');
|
||||
assert.equal(web.image, 'harness:test');
|
||||
assert.deepEqual(web.args, [
|
||||
'serve',
|
||||
'--managed-web-config',
|
||||
'/etc/qwen-web/config.json',
|
||||
'--hostname',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'4171',
|
||||
'--allow-origin',
|
||||
'https://agent.example',
|
||||
]);
|
||||
const webEnv = env(web);
|
||||
assert.equal(
|
||||
webEnv.QWEN_SERVER_TOKEN.valueFrom.secretKeyRef.key,
|
||||
'web-token',
|
||||
);
|
||||
assert(!webEnv.OPENAI_API_KEY);
|
||||
assert(!webEnv.QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY);
|
||||
assert(
|
||||
!web.volumeMounts.some((mount) =>
|
||||
['workspace', 'workspaces', 'runtime-data'].includes(mount.name),
|
||||
),
|
||||
);
|
||||
const signingVolume = pod.volumes.find(
|
||||
(volume) => volume.name === 'web-signing-key',
|
||||
);
|
||||
assert.deepEqual(signingVolume.secret.items, [
|
||||
{ key: 'auth-signing-key', path: 'auth-signing-key' },
|
||||
]);
|
||||
const config = find(objects, 'ConfigMap');
|
||||
assert.deepEqual(JSON.parse(config.data['managed-web.json']), {
|
||||
apiUrl: 'http://127.0.0.1:8080',
|
||||
tenantId: 'tenant-a',
|
||||
actorId: 'actor-a',
|
||||
signingKeyFile: '/run/qwen-web/auth-signing-key',
|
||||
});
|
||||
assert(!JSON.stringify(config).includes('fixture-key'));
|
||||
const secret = find(objects, 'Secret');
|
||||
assert.notEqual(secret.data['web-token'], secret.data['auth-signing-key']);
|
||||
assert.match(web.readinessProbe.exec.command[2], /4171\/health/);
|
||||
const service = objects.find(
|
||||
(object) => object.kind === 'Service' && object.metadata.name === 'fixture',
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.spec.ports.map((port) => port.port),
|
||||
[8080, 4171],
|
||||
);
|
||||
});
|
||||
|
||||
test('numeric browser identities and model keys render as strings', async (t) => {
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'qwen-helm-values-'));
|
||||
t.after(() => rm(temporary, { recursive: true, force: true }));
|
||||
const valuesFile = join(temporary, 'values.yaml');
|
||||
for (const value of [0, 123]) {
|
||||
await writeFile(
|
||||
valuesFile,
|
||||
`web:\n enabled: true\n tenantId: ${value}\n actorId: ${value}\nmodel:\n apiKey: ${value}\n`,
|
||||
);
|
||||
for (const args of [
|
||||
['--values', valuesFile],
|
||||
[
|
||||
'--set',
|
||||
'web.enabled=true',
|
||||
'--set',
|
||||
`web.tenantId=${value}`,
|
||||
'--set',
|
||||
`web.actorId=${value}`,
|
||||
'--set',
|
||||
`model.apiKey=${value}`,
|
||||
],
|
||||
]) {
|
||||
const objects = await render(args, inputs.slice(0, -2));
|
||||
const config = JSON.parse(
|
||||
find(objects, 'ConfigMap').data['managed-web.json'],
|
||||
);
|
||||
assert.equal(config.tenantId, String(value));
|
||||
assert.equal(config.actorId, String(value));
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
find(objects, 'Secret').data['model-api-key'],
|
||||
'base64',
|
||||
).toString(),
|
||||
String(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('missing, null or whitespace browser identities and keys fail closed', async () => {
|
||||
const web = [
|
||||
'--set',
|
||||
'web.enabled=true',
|
||||
'--set',
|
||||
'web.tenantId=tenant-a',
|
||||
'--set',
|
||||
'web.actorId=actor-a',
|
||||
];
|
||||
for (const key of ['web.tenantId', 'web.actorId', 'model.apiKey']) {
|
||||
for (const [flag, value] of [
|
||||
['--set', ''],
|
||||
['--set', 'null'],
|
||||
['--set-string', ' '],
|
||||
]) {
|
||||
await assert.rejects(render([...web, flag, `${key}=${value}`]), (error) =>
|
||||
error.message.includes(`Set ${key}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('missing installation inputs or missing upgrade credentials fail closed', async () => {
|
||||
for (const key of [
|
||||
'server.image',
|
||||
'harness.image',
|
||||
'model.id',
|
||||
'model.baseUrl',
|
||||
'model.apiKey',
|
||||
]) {
|
||||
await assert.rejects(render(['--set', `${key}=`]), /Set /);
|
||||
}
|
||||
await assert.rejects(render(['--is-upgrade']), /restore it before upgrading/);
|
||||
const external = await render([
|
||||
'--is-upgrade',
|
||||
'--set',
|
||||
'model.apiKey=',
|
||||
'--set',
|
||||
'credentials.existingSecret=operator-keys',
|
||||
]);
|
||||
assert(!find(external, 'Secret'));
|
||||
assert.equal(
|
||||
env(find(external, 'Deployment').spec.template.spec.containers[0])
|
||||
.SPRING_DATASOURCE_PASSWORD.valueFrom.secretKeyRef.name,
|
||||
'operator-keys',
|
||||
);
|
||||
});
|
||||
|
||||
test('server-side Helm rendering preserves every existing credential on upgrade', async (t) => {
|
||||
const original = find(await render(), 'Secret');
|
||||
let storedSecret = original;
|
||||
let retainedClaim;
|
||||
let retainedRuntime;
|
||||
const visited = [];
|
||||
const peer = createServer((request, response) => {
|
||||
visited.push(request.url);
|
||||
const routes = {
|
||||
'/version': { major: '1', minor: '32', gitVersion: 'v1.32.0' },
|
||||
'/api': { kind: 'APIVersions', versions: ['v1'] },
|
||||
'/apis': { kind: 'APIGroupList', groups: [] },
|
||||
'/api/v1': {
|
||||
kind: 'APIResourceList',
|
||||
groupVersion: 'v1',
|
||||
resources: [
|
||||
{
|
||||
name: 'secrets',
|
||||
kind: 'Secret',
|
||||
namespaced: true,
|
||||
verbs: ['get', 'list'],
|
||||
},
|
||||
{
|
||||
name: 'persistentvolumeclaims',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
namespaced: true,
|
||||
verbs: ['get', 'list'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/api/v1/namespaces/test-space/secrets/fixture-keys': storedSecret,
|
||||
'/api/v1/namespaces/test-space/persistentvolumeclaims/data-fixture-mysql-0':
|
||||
retainedClaim,
|
||||
'/api/v1/namespaces/test-space/persistentvolumeclaims/fixture-runtime':
|
||||
retainedRuntime,
|
||||
};
|
||||
const value = routes[request.url?.split('?')[0]];
|
||||
response.writeHead(value ? 200 : 404, {
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
response.end(
|
||||
JSON.stringify(
|
||||
value ?? {
|
||||
kind: 'Status',
|
||||
status: 'Failure',
|
||||
reason: 'NotFound',
|
||||
code: 404,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
await new Promise((ready) => peer.listen(0, '127.0.0.1', ready));
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'qwen-helm-lookup-'));
|
||||
t.after(async () => {
|
||||
peer.closeAllConnections();
|
||||
await new Promise((done) => peer.close(done));
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
});
|
||||
const kubeconfig = join(temporary, 'config');
|
||||
await writeFile(
|
||||
kubeconfig,
|
||||
JSON.stringify({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Config',
|
||||
'current-context': 'fixture',
|
||||
clusters: [
|
||||
{
|
||||
name: 'fixture',
|
||||
cluster: { server: `http://127.0.0.1:${peer.address().port}` },
|
||||
},
|
||||
],
|
||||
contexts: [
|
||||
{ name: 'fixture', context: { cluster: 'fixture', user: 'fixture' } },
|
||||
],
|
||||
users: [{ name: 'fixture', user: {} }],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const upgraded = find(
|
||||
await render([
|
||||
'--is-upgrade',
|
||||
'--dry-run=server',
|
||||
'--kubeconfig',
|
||||
kubeconfig,
|
||||
'--set',
|
||||
'model.apiKey=must-not-rotate',
|
||||
]),
|
||||
'Secret',
|
||||
);
|
||||
assert(
|
||||
visited.includes('/api/v1/namespaces/test-space/secrets/fixture-keys'),
|
||||
);
|
||||
assert.deepEqual(upgraded.data, original.data);
|
||||
storedSecret.data['operator-note'] =
|
||||
Buffer.from('preserve-extra-key').toString('base64');
|
||||
const optional = [
|
||||
'--set',
|
||||
'runtime.enabled=true',
|
||||
'--set',
|
||||
'runtime.nodeName=trusted-node',
|
||||
'--set',
|
||||
'web.enabled=true',
|
||||
'--set',
|
||||
'web.tenantId=tenant-a',
|
||||
'--set',
|
||||
'web.actorId=actor-a',
|
||||
];
|
||||
const enabled = find(
|
||||
await render([
|
||||
'--is-upgrade',
|
||||
'--dry-run=server',
|
||||
'--kubeconfig',
|
||||
kubeconfig,
|
||||
...optional,
|
||||
]),
|
||||
'Secret',
|
||||
);
|
||||
for (const [key, value] of Object.entries(storedSecret.data)) {
|
||||
assert.equal(enabled.data[key], value);
|
||||
}
|
||||
assert(enabled.data['runtime-broker-token']);
|
||||
assert(enabled.data['runtime-credential-key']);
|
||||
assert(enabled.data['web-token']);
|
||||
storedSecret = enabled;
|
||||
const disabled = find(
|
||||
await render([
|
||||
'--is-upgrade',
|
||||
'--dry-run=server',
|
||||
'--kubeconfig',
|
||||
kubeconfig,
|
||||
]),
|
||||
'Secret',
|
||||
);
|
||||
assert.deepEqual(disabled.data, enabled.data);
|
||||
const external = ['--set', 'credentials.existingSecret=fixture-keys'];
|
||||
delete storedSecret.data['runtime-credential-key'];
|
||||
retainedRuntime = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: { name: 'fixture-runtime' },
|
||||
};
|
||||
await assert.rejects(
|
||||
render([
|
||||
'--is-upgrade',
|
||||
'--dry-run=server',
|
||||
'--kubeconfig',
|
||||
kubeconfig,
|
||||
...optional,
|
||||
]),
|
||||
/retained Runtime PVC is missing its credentials/,
|
||||
);
|
||||
await assert.rejects(
|
||||
render([
|
||||
'--dry-run=server',
|
||||
'--kubeconfig',
|
||||
kubeconfig,
|
||||
...optional,
|
||||
...external,
|
||||
]),
|
||||
/missing runtime-credential-key/,
|
||||
);
|
||||
storedSecret.data['runtime-credential-key'] =
|
||||
enabled.data['runtime-broker-token'];
|
||||
storedSecret.data['web-token'] = storedSecret.data['auth-signing-key'];
|
||||
await assert.rejects(
|
||||
render([
|
||||
'--dry-run=server',
|
||||
'--kubeconfig',
|
||||
kubeconfig,
|
||||
...optional,
|
||||
...external,
|
||||
]),
|
||||
/web-token must differ/,
|
||||
);
|
||||
storedSecret = undefined;
|
||||
retainedClaim = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: { name: 'data-fixture-mysql-0' },
|
||||
};
|
||||
await assert.rejects(
|
||||
render(['--dry-run=server', '--kubeconfig', kubeconfig]),
|
||||
/retained database PVC exists without its Secret/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
*.tgz
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: 'v2'
|
||||
name: 'qwen-managed-agent'
|
||||
description: 'Single-replica Managed Agent API with Hosted Harness and MySQL'
|
||||
type: 'application'
|
||||
version: '0.1.0'
|
||||
kubeVersion: '>=1.28.0-0'
|
||||
@@ -0,0 +1,23 @@
|
||||
Managed Agent exposes a signed API.
|
||||
{{ if .Values.runtime.enabled }}
|
||||
Trusted single-node Workspace files and foreground Shell are enabled.
|
||||
Close every tool Session and confirm physical holders are clear before replacing the Pod.
|
||||
{{ else }}
|
||||
Workspace tools are disabled.
|
||||
{{ end }}
|
||||
|
||||
Connect locally:
|
||||
kubectl -n {{ .Release.Namespace }} port-forward service/{{ .Release.Name }} 8080:8080
|
||||
|
||||
Run deploy/kubernetes/smoke.mjs with the signing key from Secret {{ include "managed-agent.secret" . }}.
|
||||
See deploy/kubernetes/README.md for the command and upgrade procedure.
|
||||
{{ if .Values.web.enabled }}
|
||||
|
||||
Connect to WebShell:
|
||||
kubectl -n {{ .Release.Namespace }} port-forward service/{{ .Release.Name }} 4171:4171
|
||||
Authenticate using web-token from Secret {{ include "managed-agent.secret" . }}.
|
||||
The fixed browser identity is {{ .Values.web.tenantId }}/{{ .Values.web.actorId }}.
|
||||
{{ end }}
|
||||
|
||||
Uninstall retains the managed Secret, MySQL PVC and any Runtime/Workspace PVCs. Back them up before upgrades.
|
||||
Do not use automatic Helm rollback across database migrations.
|
||||
@@ -0,0 +1,9 @@
|
||||
{{- define "managed-agent.labels" -}}
|
||||
app.kubernetes.io/name: qwen-managed-agent
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "managed-agent.secret" -}}
|
||||
{{- default (printf "%s-keys" .Release.Name) .Values.credentials.existingSecret -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,21 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-config
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 4 }}
|
||||
data:
|
||||
settings.json: |
|
||||
{{- $model := required "Set model.id to an OpenAI-compatible model ID." .Values.model.id }}
|
||||
{{- $baseUrl := required "Set model.baseUrl to the OpenAI-compatible endpoint." .Values.model.baseUrl }}
|
||||
{{- dict "security" (dict "auth" (dict "selectedType" "openai")) "model" (dict "name" $model) "modelProviders" (dict "openai" (list (dict "id" $model "baseUrl" $baseUrl "envKey" "OPENAI_API_KEY"))) "telemetry" (dict "enabled" false) | toPrettyJson | nindent 4 }}
|
||||
mysql-init.sql: |
|
||||
GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'%';
|
||||
{{- if .Values.web.enabled }}
|
||||
managed-web.json: |
|
||||
{{- $tenantError := "Set web.tenantId for the fixed browser identity." }}
|
||||
{{- $actorError := "Set web.actorId for the fixed browser identity." }}
|
||||
{{- $tenant := required $tenantError (required $tenantError .Values.web.tenantId | toString | trim) }}
|
||||
{{- $actor := required $actorError (required $actorError .Values.web.actorId | toString | trim) }}
|
||||
{{- dict "apiUrl" "http://127.0.0.1:8080" "tenantId" $tenant "actorId" $actor "signingKeyFile" "/run/qwen-web/auth-signing-key" | toPrettyJson | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,346 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: application
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: application
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
{{- if .Values.runtime.enabled }}
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchFields:
|
||||
- key: metadata.name
|
||||
operator: In
|
||||
values:
|
||||
- {{ required "Set runtime.nodeName to the trusted Node name." .Values.runtime.nodeName | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
terminationGracePeriodSeconds: 60
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
{{- if .Values.runtime.enabled }}
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
{{- end }}
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: server
|
||||
image: {{ required "Set server.image to the built Java image." .Values.server.image | quote }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
ports:
|
||||
- name: api
|
||||
containerPort: 8080
|
||||
env:
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: {{ printf "jdbc:mysql://%s-mysql:3306/qwen_managed_agent" .Release.Name | quote }}
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
value: qwen
|
||||
{{- $secret := include "managed-agent.secret" . }}
|
||||
{{- range $env, $key := dict "SPRING_DATASOURCE_PASSWORD" "mysql-password" "QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY" "auth-signing-key" "QWEN_MANAGED_AGENT_SESSION_STORE_BINDING_KEY" "session-store-binding-key" "QWEN_MANAGED_AGENT_HARNESS_TOKEN" "harness-token" "QWEN_MANAGED_AGENT_CAPABILITY_DIGEST" "capability-digest" }}
|
||||
- name: {{ $env }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $secret }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
- name: QWEN_MANAGED_AGENT_SERVER_ADDRESS
|
||||
value: '0.0.0.0'
|
||||
- name: QWEN_MANAGED_AGENT_AUTH_MODE
|
||||
value: signed
|
||||
- name: QWEN_MANAGED_AGENT_INTERNAL_SERVER_ADDRESS
|
||||
value: '127.0.0.1'
|
||||
- name: QWEN_MANAGED_AGENT_INTERNAL_SERVER_PORT
|
||||
value: '8081'
|
||||
- name: QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL
|
||||
value: 'http://127.0.0.1:8081'
|
||||
- name: QWEN_MANAGED_AGENT_WORKSPACE_ID
|
||||
value: {{ "/workspace" | sha256sum | trunc 16 | quote }}
|
||||
- name: QWEN_MANAGED_AGENT_WORKSPACE_CWD
|
||||
value: /workspace
|
||||
- name: QWEN_MANAGED_AGENT_HARNESS_ENABLED
|
||||
value: 'true'
|
||||
- name: QWEN_MANAGED_AGENT_HARNESS_BASE_URL
|
||||
value: 'http://127.0.0.1:4170'
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED
|
||||
value: {{ .Values.runtime.enabled | quote }}
|
||||
- name: QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED
|
||||
value: {{ .Values.runtime.enabled | quote }}
|
||||
- name: QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED
|
||||
value: {{ .Values.runtime.enabled | quote }}
|
||||
{{- if .Values.runtime.enabled }}
|
||||
{{- range $env, $key := dict "QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN" "runtime-broker-token" "QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY" "runtime-credential-key" }}
|
||||
- name: {{ $env }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $secret }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY_ID
|
||||
value: kubernetes-v1
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_PROVISIONER
|
||||
value: local-process
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_ISOLATION
|
||||
value: session
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY
|
||||
value: /runtime-data/state
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS
|
||||
value: 'true'
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_VERIFIED_WORKSPACE_RECOVERY_ENABLED
|
||||
value: 'true'
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY
|
||||
value: 'false'
|
||||
- name: QWEN_MANAGED_AGENT_WORKSPACE_CREATION_ROOT
|
||||
value: /workspaces
|
||||
- name: QWEN_MANAGED_AGENT_NODE_EXECUTABLE
|
||||
value: /usr/local/bin/node
|
||||
- name: QWEN_MANAGED_AGENT_RUNTIME_WORKER_ENTRY
|
||||
value: /usr/local/share/npm-global/lib/node_modules/@qwen-code/qwen-code/cli.js
|
||||
- name: QWEN_MANAGED_AGENT_APPROVAL_MODE
|
||||
value: default
|
||||
- name: QWEN_MANAGED_AGENT_WORKSPACE_CHILD_AGENTS_ENABLED
|
||||
value: 'false'
|
||||
- name: QWEN_MANAGED_AGENT_TOOL_PUBLICATION_ENABLED
|
||||
value: 'false'
|
||||
- name: QWEN_HOME
|
||||
value: /runtime-data/home
|
||||
- name: QWEN_RUNTIME_DIR
|
||||
value: /runtime-data/home/runtime
|
||||
{{- end }}
|
||||
- name: MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED
|
||||
value: 'true'
|
||||
- name: MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE
|
||||
value: readinessState,db
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: api
|
||||
periodSeconds: 5
|
||||
failureThreshold: 120
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: api
|
||||
timeoutSeconds: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: api
|
||||
timeoutSeconds: 3
|
||||
resources:
|
||||
{{- toYaml .Values.server.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
- name: server-tmp
|
||||
mountPath: /tmp
|
||||
{{- if .Values.runtime.enabled }}
|
||||
- name: workspaces
|
||||
mountPath: /workspaces
|
||||
- name: runtime-data
|
||||
mountPath: /runtime-data
|
||||
- name: machine-id
|
||||
mountPath: /etc/machine-id
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
- name: harness
|
||||
image: {{ required "Set harness.image to the matching built Qwen CLI image." .Values.harness.image | quote }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
command: [qwen]
|
||||
args: [serve, --profile, hosted-harness, --hostname, '127.0.0.1', --port, '4170', --no-web]
|
||||
workingDir: /workspace
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
env:
|
||||
- name: QWEN_HOME
|
||||
value: /home/node/.qwen
|
||||
- name: QWEN_RUNTIME_DIR
|
||||
value: /home/node/.qwen/runtime
|
||||
{{- if .Values.runtime.enabled }}
|
||||
- name: QWEN_MANAGED_RUNTIME_BROKER_URL
|
||||
value: 'http://127.0.0.1:4182'
|
||||
- name: QWEN_MANAGED_RUNTIME_BROKER_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $secret }}
|
||||
key: runtime-broker-token
|
||||
{{- end }}
|
||||
{{- range $env, $key := dict "QWEN_SERVER_TOKEN" "harness-token" "QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST" "capability-digest" "OPENAI_API_KEY" "model-api-key" }}
|
||||
- name: {{ $env }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $secret }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
startupProbe:
|
||||
exec:
|
||||
command: &harness-health
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:4170/health',{headers:{authorization:'Bearer '+process.env.QWEN_SERVER_TOKEN},signal:AbortSignal.timeout(2000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 60
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: *harness-health
|
||||
timeoutSeconds: 3
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: *harness-health
|
||||
timeoutSeconds: 3
|
||||
resources:
|
||||
{{- toYaml .Values.harness.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: harness-home
|
||||
mountPath: /home/node/.qwen
|
||||
- name: config
|
||||
mountPath: /home/node/.qwen/settings.json
|
||||
subPath: settings.json
|
||||
readOnly: true
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
- name: harness-tmp
|
||||
mountPath: /tmp
|
||||
{{- if .Values.web.enabled }}
|
||||
- name: web
|
||||
image: {{ default .Values.harness.image .Values.web.image | quote }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
command: [qwen]
|
||||
args:
|
||||
- serve
|
||||
- --managed-web-config
|
||||
- /etc/qwen-web/config.json
|
||||
- --hostname
|
||||
- '0.0.0.0'
|
||||
- --port
|
||||
- '4171'
|
||||
{{- range .Values.web.allowOrigins }}
|
||||
- --allow-origin
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
workingDir: /home/node/.qwen
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
ports:
|
||||
- name: web
|
||||
containerPort: 4171
|
||||
env:
|
||||
- name: QWEN_HOME
|
||||
value: /home/node/.qwen
|
||||
- name: QWEN_RUNTIME_DIR
|
||||
value: /home/node/.qwen/runtime
|
||||
- name: QWEN_SERVER_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $secret }}
|
||||
key: web-token
|
||||
startupProbe:
|
||||
exec:
|
||||
command: &web-health
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:4171/health',{headers:{authorization:'Bearer '+process.env.QWEN_SERVER_TOKEN},signal:AbortSignal.timeout(2000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 60
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: *web-health
|
||||
timeoutSeconds: 3
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: *web-health
|
||||
timeoutSeconds: 3
|
||||
resources:
|
||||
{{- toYaml .Values.web.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: web-home
|
||||
mountPath: /home/node/.qwen
|
||||
- name: web-tmp
|
||||
mountPath: /tmp
|
||||
- name: config
|
||||
mountPath: /etc/qwen-web/config.json
|
||||
subPath: managed-web.json
|
||||
readOnly: true
|
||||
- name: web-signing-key
|
||||
mountPath: /run/qwen-web
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ .Release.Name }}-config
|
||||
- name: harness-home
|
||||
emptyDir: {}
|
||||
- name: workspace
|
||||
emptyDir: {}
|
||||
- name: server-tmp
|
||||
emptyDir: {}
|
||||
- name: harness-tmp
|
||||
emptyDir: {}
|
||||
{{- if .Values.runtime.enabled }}
|
||||
- name: workspaces
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-workspaces
|
||||
- name: runtime-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-runtime
|
||||
- name: machine-id
|
||||
hostPath:
|
||||
path: /etc/machine-id
|
||||
type: File
|
||||
{{- end }}
|
||||
{{- if .Values.web.enabled }}
|
||||
- name: web-home
|
||||
emptyDir: {}
|
||||
- name: web-tmp
|
||||
emptyDir: {}
|
||||
- name: web-signing-key
|
||||
secret:
|
||||
secretName: {{ $secret }}
|
||||
defaultMode: 0440
|
||||
items:
|
||||
- key: auth-signing-key
|
||||
path: auth-signing-key
|
||||
{{- end }}
|
||||
@@ -0,0 +1,102 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mysql
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 4 }}
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mysql
|
||||
ports:
|
||||
- name: mysql
|
||||
port: 3306
|
||||
targetPort: mysql
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mysql
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 4 }}
|
||||
spec:
|
||||
serviceName: {{ .Release.Name }}-mysql
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mysql
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: mysql
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: mysql
|
||||
image: {{ .Values.mysql.image | quote }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
ports:
|
||||
- name: mysql
|
||||
containerPort: 3306
|
||||
env:
|
||||
- name: MYSQL_DATABASE
|
||||
value: qwen_managed_agent
|
||||
- name: MYSQL_USER
|
||||
value: qwen
|
||||
- name: MYSQL_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "managed-agent.secret" . }}
|
||||
key: mysql-password
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "managed-agent.secret" . }}
|
||||
key: mysql-root-password
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: mysql
|
||||
periodSeconds: 5
|
||||
failureThreshold: 120
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: mysql
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'MYSQL_PWD="$MYSQL_PASSWORD" mysql --host=127.0.0.1 --user=qwen --database=qwen_managed_agent --execute="SELECT 1; SELECT 1 FROM performance_schema.user_variables_by_thread LIMIT 0"'
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
{{- toYaml .Values.mysql.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/mysql
|
||||
subPath: mysql
|
||||
- name: config
|
||||
mountPath: /docker-entrypoint-initdb.d/qwen.sql
|
||||
subPath: mysql-init.sql
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ .Release.Name }}-config
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
{{- with .Values.mysql.storage.className }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.mysql.storage.size | quote }}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if .Values.runtime.enabled }}
|
||||
{{- range $suffix, $size := dict "workspaces" .Values.runtime.storage.workspaceSize "runtime" .Values.runtime.storage.stateSize }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ $.Release.Name }}-{{ $suffix }}
|
||||
labels:
|
||||
{{- include "managed-agent.labels" $ | nindent 4 }}
|
||||
annotations:
|
||||
helm.sh/resource-policy: keep
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
{{- with $.Values.runtime.storage.className }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ $size | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,72 @@
|
||||
{{- $name := include "managed-agent.secret" . }}
|
||||
{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }}
|
||||
{{- $required := list "auth-signing-key" "session-store-binding-key" "harness-token" "capability-digest" "mysql-password" "mysql-root-password" "model-api-key" }}
|
||||
{{- if .Values.credentials.existingSecret }}
|
||||
{{- if .Values.runtime.enabled }}
|
||||
{{- $required = concat $required (list "runtime-broker-token" "runtime-credential-key") }}
|
||||
{{- end }}
|
||||
{{- if .Values.web.enabled }}
|
||||
{{- $required = append $required "web-token" }}
|
||||
{{- end }}
|
||||
{{- if $existing }}
|
||||
{{- range $key := $required }}
|
||||
{{- $_ := required (printf "Existing Secret %s is missing %s" $name $key) (get $existing.data $key) }}
|
||||
{{- end }}
|
||||
{{- if and .Values.web.enabled (eq (get $existing.data "web-token") (get $existing.data "auth-signing-key")) }}
|
||||
{{- fail "web-token must differ from auth-signing-key." }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
{{- if and .Release.IsUpgrade (not $existing) }}
|
||||
{{- fail "The deployment Secret is missing; restore it before upgrading an existing database." }}
|
||||
{{- end }}
|
||||
{{- if and (not $existing) (lookup "v1" "PersistentVolumeClaim" .Release.Namespace (printf "data-%s-mysql-0" .Release.Name)) }}
|
||||
{{- fail "A retained database PVC exists without its Secret; restore the original credentials before installing." }}
|
||||
{{- end }}
|
||||
{{- $data := dict }}
|
||||
{{- if $existing }}
|
||||
{{- $data = deepCopy $existing.data }}
|
||||
{{- range $key := $required }}
|
||||
{{- $_ := required (printf "Existing Secret %s is missing %s" $name $key) (get $data $key) }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
{{- $_ := set $data "auth-signing-key" (randAlphaNum 64 | b64enc) }}
|
||||
{{- $_ := set $data "session-store-binding-key" (randAlphaNum 64 | b64enc) }}
|
||||
{{- $_ := set $data "harness-token" (randAlphaNum 64 | b64enc) }}
|
||||
{{- $_ := set $data "capability-digest" (printf "sha256:%s" (randAlphaNum 64 | sha256sum) | b64enc) }}
|
||||
{{- $_ := set $data "mysql-password" (randAlphaNum 32 | b64enc) }}
|
||||
{{- $_ := set $data "mysql-root-password" (randAlphaNum 32 | b64enc) }}
|
||||
{{- $keyError := "Set model.apiKey with --set-file, or supply credentials.existingSecret." }}
|
||||
{{- $_ := set $data "model-api-key" (required $keyError (required $keyError .Values.model.apiKey | toString | trim) | b64enc) }}
|
||||
{{- end }}
|
||||
{{- if .Values.runtime.enabled }}
|
||||
{{- if and (or (not (hasKey $data "runtime-broker-token")) (not (hasKey $data "runtime-credential-key"))) (lookup "v1" "PersistentVolumeClaim" .Release.Namespace (printf "%s-runtime" .Release.Name)) }}
|
||||
{{- fail "A retained Runtime PVC is missing its credentials; restore the original Secret before enabling Runtime." }}
|
||||
{{- end }}
|
||||
{{- if not (hasKey $data "runtime-broker-token") }}
|
||||
{{- $_ := set $data "runtime-broker-token" (randAlphaNum 64 | b64enc) }}
|
||||
{{- end }}
|
||||
{{- if not (hasKey $data "runtime-credential-key") }}
|
||||
{{- $_ := set $data "runtime-credential-key" (randBytes 32 | b64enc) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.web.enabled }}
|
||||
{{- if not (hasKey $data "web-token") }}
|
||||
{{- $_ := set $data "web-token" (randAlphaNum 64 | b64enc) }}
|
||||
{{- end }}
|
||||
{{- if eq (get $data "web-token") (get $data "auth-signing-key") }}
|
||||
{{- fail "web-token must differ from auth-signing-key." }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
helm.sh/resource-policy: keep
|
||||
type: Opaque
|
||||
data:
|
||||
{{- toYaml $data | nindent 2 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
labels:
|
||||
{{- include "managed-agent.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: application
|
||||
ports:
|
||||
- name: api
|
||||
port: 8080
|
||||
targetPort: api
|
||||
{{- if .Values.web.enabled }}
|
||||
- name: web
|
||||
port: 4171
|
||||
targetPort: web
|
||||
{{- end }}
|
||||
@@ -0,0 +1,66 @@
|
||||
server:
|
||||
image: ''
|
||||
resources:
|
||||
requests:
|
||||
cpu: '250m'
|
||||
memory: '512Mi'
|
||||
limits:
|
||||
memory: '2Gi'
|
||||
|
||||
harness:
|
||||
image: ''
|
||||
resources:
|
||||
requests:
|
||||
cpu: '250m'
|
||||
memory: '512Mi'
|
||||
limits:
|
||||
memory: '2Gi'
|
||||
|
||||
runtime:
|
||||
enabled: false
|
||||
# Required when enabled: the exact trusted Kubernetes Node name.
|
||||
nodeName: ''
|
||||
storage:
|
||||
workspaceSize: '10Gi'
|
||||
stateSize: '1Gi'
|
||||
className: ''
|
||||
|
||||
web:
|
||||
enabled: false
|
||||
# Empty reuses harness.image; it must include --managed-web-config.
|
||||
image: ''
|
||||
tenantId: ''
|
||||
actorId: ''
|
||||
allowOrigins: []
|
||||
resources:
|
||||
requests:
|
||||
cpu: '100m'
|
||||
memory: '128Mi'
|
||||
limits:
|
||||
memory: '512Mi'
|
||||
|
||||
model:
|
||||
id: ''
|
||||
baseUrl: ''
|
||||
# Only used when creating the managed Secret; prefer --set-file.
|
||||
apiKey: ''
|
||||
|
||||
credentials:
|
||||
# See the README for required keys. Empty means generate and retain a Secret.
|
||||
existingSecret: ''
|
||||
|
||||
mysql:
|
||||
image: 'mysql:8.4'
|
||||
storage:
|
||||
size: '10Gi'
|
||||
# Empty uses the cluster's default StorageClass.
|
||||
className: ''
|
||||
resources:
|
||||
requests:
|
||||
cpu: '250m'
|
||||
memory: '512Mi'
|
||||
limits:
|
||||
memory: '2Gi'
|
||||
|
||||
imagePullPolicy: 'IfNotPresent'
|
||||
imagePullSecrets: []
|
||||
@@ -0,0 +1,22 @@
|
||||
ARG SERVER_IMAGE
|
||||
ARG HARNESS_IMAGE
|
||||
FROM ${SERVER_IMAGE} AS server
|
||||
|
||||
FROM ${HARNESS_IMAGE}
|
||||
|
||||
USER root
|
||||
COPY --from=server /opt/java/openjdk /opt/java/openjdk
|
||||
COPY --from=server /app/qwen-managed-agent-server.jar /app/qwen-managed-agent-server.jar
|
||||
COPY --chmod=755 deploy/kubernetes/start-server.sh /opt/qwen/bin/start-server
|
||||
ENV JAVA_HOME=/opt/java/openjdk
|
||||
ENV PATH=/opt/java/openjdk/bin:$PATH
|
||||
|
||||
RUN useradd --uid 10001 --gid 1000 --home-dir /runtime-data/home --no-create-home qwen-managed \
|
||||
&& node --version \
|
||||
&& java -version \
|
||||
&& test -r /usr/local/share/npm-global/lib/node_modules/@qwen-code/qwen-code/cli.js \
|
||||
&& test -x /usr/bin/stat
|
||||
|
||||
USER 10001
|
||||
ENTRYPOINT ["/opt/qwen/bin/start-server"]
|
||||
CMD []
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const script = fileURLToPath(new URL('./start-server.sh', import.meta.url));
|
||||
|
||||
test(
|
||||
'combined launcher accepts explicitly disabled Runtime and rejects partial configuration',
|
||||
{ skip: process.platform === 'win32' },
|
||||
async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'qwen-runtime-disabled-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const launched = join(root, 'launched');
|
||||
await mkdir(join(root, 'bin'));
|
||||
await writeFile(
|
||||
join(root, 'bin', 'java'),
|
||||
'#!/bin/sh\nprintf "%s\\n" "$@" > "$QWEN_TEST_LAUNCHED"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const environment = {
|
||||
...process.env,
|
||||
JAVA_HOME: root,
|
||||
QWEN_TEST_LAUNCHED: launched,
|
||||
};
|
||||
delete environment.QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED;
|
||||
delete environment.QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY;
|
||||
delete environment.QWEN_HOME;
|
||||
await execute('/bin/sh', [script, '--spring.profiles.active=test'], {
|
||||
env: {
|
||||
...environment,
|
||||
QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'false',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await readFile(launched, 'utf8'),
|
||||
'-jar\n/app/qwen-managed-agent-server.jar\n--spring.profiles.active=test\n',
|
||||
);
|
||||
await rm(launched);
|
||||
for (const overrides of [
|
||||
{},
|
||||
{ QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'true' },
|
||||
{ QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'invalid' },
|
||||
{
|
||||
QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'false',
|
||||
QWEN_HOME: join(root, 'home'),
|
||||
},
|
||||
{
|
||||
QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'false',
|
||||
QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY: join(root, 'state'),
|
||||
},
|
||||
]) {
|
||||
await assert.rejects(
|
||||
execute('/bin/sh', [script], {
|
||||
env: { ...environment, ...overrides },
|
||||
}),
|
||||
);
|
||||
await assert.rejects(stat(launched), { code: 'ENOENT' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'LOCAL launcher restores private modes and rejects symlinks or unowned entries',
|
||||
{ skip: process.platform === 'win32' },
|
||||
async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'qwen-runtime-launcher-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const state = join(root, 'state');
|
||||
const home = join(root, 'home');
|
||||
const bin = join(root, 'bin');
|
||||
const launched = join(root, 'launched');
|
||||
await mkdir(state);
|
||||
await mkdir(bin);
|
||||
const record = join(state, 'record.json');
|
||||
await writeFile(record, '{}');
|
||||
await chmod(state, 0o750);
|
||||
await chmod(record, 0o640);
|
||||
await writeFile(
|
||||
join(bin, 'java'),
|
||||
'#!/bin/sh\nprintf "%s\\n" "$@" > "$QWEN_TEST_LAUNCHED"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const environment = {
|
||||
...process.env,
|
||||
JAVA_HOME: root,
|
||||
QWEN_HOME: home,
|
||||
QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY: state,
|
||||
QWEN_TEST_LAUNCHED: launched,
|
||||
};
|
||||
await execute('/bin/sh', [script], { env: environment });
|
||||
assert.equal((await stat(state)).mode & 0o777, 0o700);
|
||||
assert.equal((await stat(record)).mode & 0o777, 0o600);
|
||||
assert.equal((await stat(home)).mode & 0o777, 0o700);
|
||||
assert.equal(
|
||||
await readFile(launched, 'utf8'),
|
||||
'-jar\n/app/qwen-managed-agent-server.jar\n',
|
||||
);
|
||||
|
||||
await rm(launched);
|
||||
const link = join(state, 'link');
|
||||
await symlink(record, link);
|
||||
await assert.rejects(
|
||||
execute('/bin/sh', [script], { env: environment }),
|
||||
/unsupported entry/,
|
||||
);
|
||||
await assert.rejects(stat(launched), { code: 'ENOENT' });
|
||||
await rm(link);
|
||||
await chmod(record, 0o640);
|
||||
await writeFile(join(bin, 'id'), '#!/bin/sh\necho 4294967294\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
await assert.rejects(
|
||||
execute('/bin/sh', [script], {
|
||||
env: { ...environment, PATH: `${bin}:${process.env.PATH}` },
|
||||
}),
|
||||
/unowned/,
|
||||
);
|
||||
assert.equal((await stat(record)).mode & 0o777, 0o640);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* global fetch, AbortSignal */
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { pathToFileURL, URL, URLSearchParams } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
export function signedHeaders(
|
||||
method,
|
||||
url,
|
||||
body,
|
||||
key,
|
||||
tenant,
|
||||
actor,
|
||||
requestKey,
|
||||
) {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const canonical = [
|
||||
'qwen-broker-auth-v1',
|
||||
method,
|
||||
url.pathname,
|
||||
url.search.slice(1),
|
||||
tenant,
|
||||
actor,
|
||||
timestamp,
|
||||
createHash('sha256').update(body).digest('hex'),
|
||||
requestKey ?? '',
|
||||
].join('\n');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Qwen-Tenant-Id': tenant,
|
||||
'X-Qwen-Actor-Id': actor,
|
||||
'X-Qwen-Signature-Timestamp': timestamp,
|
||||
'X-Qwen-Signature': `v1=${createHmac('sha256', key).update(canonical).digest('hex')}`,
|
||||
...(requestKey ? { 'Idempotency-Key': requestKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSmoke({
|
||||
baseUrl,
|
||||
signingKey,
|
||||
tenant = 'k8s-smoke',
|
||||
actor = 'operator',
|
||||
sessionId,
|
||||
keepSession = false,
|
||||
timeoutMs = 120_000,
|
||||
}) {
|
||||
const base = new URL(baseUrl);
|
||||
assert(
|
||||
['http:', 'https:'].includes(base.protocol),
|
||||
'Use an HTTP(S) API URL.',
|
||||
);
|
||||
assert(
|
||||
!base.username && !base.password,
|
||||
'URL credentials are not supported.',
|
||||
);
|
||||
assert(
|
||||
Buffer.byteLength(signingKey) >= 32,
|
||||
'The signing key must have at least 32 bytes.',
|
||||
);
|
||||
assert(
|
||||
Number.isSafeInteger(timeoutMs) && timeoutMs > 0,
|
||||
'Timeout must be a positive integer.',
|
||||
);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const signal = () => {
|
||||
assert(Date.now() < deadline, 'Smoke timed out.');
|
||||
return AbortSignal.timeout(Math.min(10_000, deadline - Date.now()));
|
||||
};
|
||||
const request = async (method, path, value) => {
|
||||
const url = new URL(path, base);
|
||||
const body = value === undefined ? '' : JSON.stringify(value);
|
||||
const key = method === 'GET' ? undefined : randomUUID();
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: signedHeaders(method, url, body, signingKey, tenant, actor, key),
|
||||
...(body ? { body } : {}),
|
||||
redirect: 'error',
|
||||
signal: signal(),
|
||||
});
|
||||
assert(response.ok, `${method} ${path}: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
};
|
||||
const wait = async (path) => {
|
||||
while (true) {
|
||||
const result = await request('GET', path);
|
||||
if (result.status === 'completed') return result;
|
||||
assert(
|
||||
['accepted', 'pending', 'running', 'cancelling'].includes(
|
||||
result.status,
|
||||
),
|
||||
`Operation ${result.status}: ${result.error_code ?? result.failure_code ?? 'no error code'}`,
|
||||
);
|
||||
await setTimeout(Math.min(1000, Math.max(0, deadline - Date.now())));
|
||||
}
|
||||
};
|
||||
const historyContains = async (path, predicate) => {
|
||||
let cursor;
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: '100' });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
const items = await request('GET', `${path}/items?${query}`);
|
||||
if (items.data.some(predicate)) return true;
|
||||
cursor = items.has_more ? items.next_cursor : undefined;
|
||||
} while (cursor);
|
||||
return false;
|
||||
};
|
||||
|
||||
const unsigned = await fetch(new URL('/v1/agents/sessions', base), {
|
||||
headers: { 'X-Qwen-Tenant-Id': tenant },
|
||||
redirect: 'error',
|
||||
signal: signal(),
|
||||
});
|
||||
assert.equal(unsigned.status, 401, 'Unsigned requests must be refused.');
|
||||
await unsigned.body?.cancel();
|
||||
|
||||
try {
|
||||
if (sessionId) {
|
||||
assert(
|
||||
await historyContains(
|
||||
`/v1/agents/sessions/${sessionId}`,
|
||||
(item) => item.role === 'assistant',
|
||||
),
|
||||
'Previous assistant history is missing.',
|
||||
);
|
||||
} else {
|
||||
const created = await request('POST', '/v1/agents/sessions', {
|
||||
agent_id: 'qwen-code',
|
||||
});
|
||||
sessionId = created.id;
|
||||
assert(sessionId, 'Session creation returned no ID.');
|
||||
}
|
||||
const path = `/v1/agents/sessions/${sessionId}`;
|
||||
const marker = `K8S_SMOKE_${randomUUID().replaceAll('-', '')}`;
|
||||
const input = await request('POST', `${path}/events`, {
|
||||
type: 'agent.session.input.message',
|
||||
input: [{ type: 'input_text', text: `Reply with exactly ${marker}.` }],
|
||||
});
|
||||
assert(input.turn_id, 'Input admission returned no Turn ID.');
|
||||
await wait(`${path}/turns/${input.turn_id}`);
|
||||
assert(
|
||||
await historyContains(
|
||||
path,
|
||||
(item) =>
|
||||
item.role === 'assistant' &&
|
||||
item.content?.some(
|
||||
(part) =>
|
||||
part.type === 'output_text' && part.text?.includes(marker),
|
||||
),
|
||||
),
|
||||
'Assistant history does not contain the smoke response.',
|
||||
);
|
||||
if (!keepSession) {
|
||||
const operation = await request('POST', `${path}/close`);
|
||||
assert(operation.id, 'Close admission returned no operation ID.');
|
||||
await wait(`${path}/operations/${operation.id}`);
|
||||
const closed = await request('GET', path);
|
||||
assert.equal(closed.status, 'closed');
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
turnId: input.turn_id,
|
||||
status: 'completed',
|
||||
closed: !keepSession,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Smoke failed (Session ${sessionId ?? 'not created'}): ${error.message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
url: { type: 'string', default: 'http://127.0.0.1:8080' },
|
||||
'key-file': { type: 'string' },
|
||||
tenant: { type: 'string', default: 'k8s-smoke' },
|
||||
actor: { type: 'string', default: 'operator' },
|
||||
'session-id': { type: 'string' },
|
||||
'keep-session': { type: 'boolean', default: false },
|
||||
'timeout-ms': { type: 'string', default: '120000' },
|
||||
},
|
||||
});
|
||||
assert(
|
||||
values['key-file'],
|
||||
'Pass --key-file pointing to the auth-signing-key.',
|
||||
);
|
||||
const signingKey = await readFile(values['key-file'], 'utf8');
|
||||
const result = await runSmoke({
|
||||
baseUrl: values.url,
|
||||
signingKey,
|
||||
tenant: values.tenant,
|
||||
actor: values.actor,
|
||||
sessionId: values['session-id'],
|
||||
keepSession: values['keep-session'],
|
||||
timeoutMs: Number(values['timeout-ms']),
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { runSmoke, signedHeaders } from './smoke.mjs';
|
||||
|
||||
const signingKey = 'fixture-signing-key-with-at-least-thirty-two-bytes';
|
||||
|
||||
async function apiPeer(
|
||||
t,
|
||||
{
|
||||
output = true,
|
||||
turnStatus = 'completed',
|
||||
unsignedStatus = 401,
|
||||
key = signingKey,
|
||||
paginated = false,
|
||||
retainedOnNextPage = false,
|
||||
} = {},
|
||||
) {
|
||||
const state = {
|
||||
inputs: 0,
|
||||
closed: false,
|
||||
signatureErrors: 0,
|
||||
marker: '',
|
||||
itemCursors: [],
|
||||
};
|
||||
const peer = createServer(async (request, response) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
const url = new URL(request.url, 'http://fixture');
|
||||
const headers = request.headers;
|
||||
const send = (status, value) => {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify(value));
|
||||
};
|
||||
if (!headers['x-qwen-signature']) return send(unsignedStatus, {});
|
||||
const canonical = `qwen-broker-auth-v1\n${request.method}\n${url.pathname}\n${url.search.slice(1)}\n${headers['x-qwen-tenant-id']}\n${headers['x-qwen-actor-id']}\n${headers['x-qwen-signature-timestamp']}\n${createHash('sha256').update(body).digest('hex')}\n${headers['idempotency-key'] ?? ''}`;
|
||||
if (
|
||||
headers['x-qwen-signature'] !==
|
||||
`v1=${createHmac('sha256', key).update(canonical).digest('hex')}` ||
|
||||
headers['x-qwen-tenant-id'] !== 'k8s-smoke' ||
|
||||
headers['x-qwen-actor-id'] !== 'operator' ||
|
||||
(request.method === 'POST' && !headers['idempotency-key'])
|
||||
) {
|
||||
state.signatureErrors++;
|
||||
return send(401, {});
|
||||
}
|
||||
const root = '/v1/agents/sessions';
|
||||
if (request.method === 'POST' && url.pathname === root) {
|
||||
assert.deepEqual(JSON.parse(body), { agent_id: 'qwen-code' });
|
||||
return send(202, { id: 'session-1' });
|
||||
}
|
||||
if (
|
||||
request.method === 'POST' &&
|
||||
url.pathname === `${root}/session-1/events`
|
||||
) {
|
||||
state.inputs++;
|
||||
const input = JSON.parse(body);
|
||||
assert.equal(input.type, 'agent.session.input.message');
|
||||
state.marker = input.input[0].text.match(/K8S_SMOKE_[a-f0-9]+/)[0];
|
||||
return send(202, { turn_id: 'turn-1' });
|
||||
}
|
||||
if (url.pathname === `${root}/session-1/turns/turn-1`) {
|
||||
return send(200, { status: turnStatus, error_code: 'fixture_failure' });
|
||||
}
|
||||
if (url.pathname === `${root}/session-1/items`) {
|
||||
const cursor = url.searchParams.get('cursor');
|
||||
state.itemCursors.push(cursor);
|
||||
if (paginated && !cursor) {
|
||||
return send(200, {
|
||||
data: Array.from({ length: 100 }, () => ({
|
||||
role: retainedOnNextPage ? 'user' : 'assistant',
|
||||
content: [{ type: 'output_text', text: 'old response' }],
|
||||
})),
|
||||
has_more: true,
|
||||
next_cursor: 'snapshot/+first=',
|
||||
});
|
||||
}
|
||||
if (paginated) assert.equal(cursor, 'snapshot/+first=');
|
||||
return send(200, {
|
||||
data: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: output ? state.marker : 'old response',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
}
|
||||
if (
|
||||
request.method === 'POST' &&
|
||||
url.pathname === `${root}/session-1/close`
|
||||
) {
|
||||
state.closed = true;
|
||||
return send(202, { id: 'close-1' });
|
||||
}
|
||||
if (url.pathname === `${root}/session-1/operations/close-1`) {
|
||||
return send(200, { status: 'completed' });
|
||||
}
|
||||
if (url.pathname === `${root}/session-1`) {
|
||||
return send(200, { status: state.closed ? 'closed' : 'active' });
|
||||
}
|
||||
send(404, {});
|
||||
});
|
||||
await new Promise((ready) => peer.listen(0, '127.0.0.1', ready));
|
||||
t.after(async () => {
|
||||
peer.closeAllConnections();
|
||||
await new Promise((done) => peer.close(done));
|
||||
});
|
||||
return {
|
||||
state,
|
||||
baseUrl: `http://127.0.0.1:${peer.address().port}`,
|
||||
signingKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
test('signs UTF-8 body, raw query and idempotency key in the Java canonical order', () => {
|
||||
const url = new URL('https://api.example/v1/agents/sessions?q=a%20b');
|
||||
const body = '{"text":"你好"}';
|
||||
const headers = signedHeaders(
|
||||
'POST',
|
||||
url,
|
||||
body,
|
||||
signingKey,
|
||||
'tenant',
|
||||
'actor',
|
||||
'request-1',
|
||||
);
|
||||
const canonical = [
|
||||
'qwen-broker-auth-v1',
|
||||
'POST',
|
||||
'/v1/agents/sessions',
|
||||
'q=a%20b',
|
||||
'tenant',
|
||||
'actor',
|
||||
headers['X-Qwen-Signature-Timestamp'],
|
||||
createHash('sha256').update(body).digest('hex'),
|
||||
'request-1',
|
||||
].join('\n');
|
||||
assert.equal(
|
||||
headers['X-Qwen-Signature'],
|
||||
`v1=${createHmac('sha256', signingKey).update(canonical).digest('hex')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('smoke requires authenticated completion and history, then closes', async (t) => {
|
||||
const fixture = await apiPeer(t);
|
||||
const result = await runSmoke(fixture);
|
||||
assert.deepEqual(result, {
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-1',
|
||||
status: 'completed',
|
||||
closed: true,
|
||||
});
|
||||
assert.equal(fixture.state.inputs, 1);
|
||||
assert.equal(fixture.state.closed, true);
|
||||
assert.equal(fixture.state.signatureErrors, 0);
|
||||
});
|
||||
|
||||
test('CLI preserves trailing whitespace in operator-managed signing keys', async (t) => {
|
||||
const key = `${signingKey}\n `;
|
||||
const fixture = await apiPeer(t, { key });
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'qwen-smoke-key-'));
|
||||
t.after(() => rm(temporary, { recursive: true, force: true }));
|
||||
const keyFile = join(temporary, 'signing-key');
|
||||
await writeFile(keyFile, key, { mode: 0o600 });
|
||||
const { stdout } = await promisify(execFile)(process.execPath, [
|
||||
fileURLToPath(new URL('./smoke.mjs', import.meta.url)),
|
||||
'--url',
|
||||
fixture.baseUrl,
|
||||
'--key-file',
|
||||
keyFile,
|
||||
]);
|
||||
assert.equal(JSON.parse(stdout).closed, true);
|
||||
assert.equal(fixture.state.signatureErrors, 0);
|
||||
});
|
||||
|
||||
test('idle restart workflow preserves the Session until explicitly resumed', async (t) => {
|
||||
const fixture = await apiPeer(t);
|
||||
const kept = await runSmoke({ ...fixture, keepSession: true });
|
||||
assert.equal(kept.closed, false);
|
||||
assert.equal(fixture.state.closed, false);
|
||||
const resumed = await runSmoke({ ...fixture, sessionId: kept.sessionId });
|
||||
assert.equal(resumed.closed, true);
|
||||
assert.equal(fixture.state.inputs, 2);
|
||||
});
|
||||
|
||||
test('a completed Turn with missing model output cannot pass', async (t) => {
|
||||
const fixture = await apiPeer(t, { output: false });
|
||||
await assert.rejects(
|
||||
runSmoke(fixture),
|
||||
/Session session-1.*does not contain/,
|
||||
);
|
||||
assert.equal(fixture.state.closed, false);
|
||||
});
|
||||
|
||||
test('a reused long Session follows signed history cursors to the new output', async (t) => {
|
||||
const fixture = await apiPeer(t, { paginated: true });
|
||||
const result = await runSmoke({ ...fixture, sessionId: 'session-1' });
|
||||
assert.equal(result.closed, true);
|
||||
assert.deepEqual(fixture.state.itemCursors, [null, null, 'snapshot/+first=']);
|
||||
assert.equal(fixture.state.inputs, 1);
|
||||
assert.equal(fixture.state.signatureErrors, 0);
|
||||
});
|
||||
|
||||
test('exhausted history pages without the new marker still fail', async (t) => {
|
||||
const fixture = await apiPeer(t, { paginated: true, output: false });
|
||||
await assert.rejects(
|
||||
runSmoke({ ...fixture, sessionId: 'session-1' }),
|
||||
/does not contain the smoke response/,
|
||||
);
|
||||
assert.equal(fixture.state.inputs, 1);
|
||||
assert.equal(fixture.state.closed, false);
|
||||
assert.equal(fixture.state.signatureErrors, 0);
|
||||
});
|
||||
|
||||
test('reused history can start with a full page without an assistant', async (t) => {
|
||||
const fixture = await apiPeer(t, {
|
||||
paginated: true,
|
||||
retainedOnNextPage: true,
|
||||
});
|
||||
const result = await runSmoke({ ...fixture, sessionId: 'session-1' });
|
||||
assert.equal(result.closed, true);
|
||||
assert.deepEqual(fixture.state.itemCursors, [
|
||||
null,
|
||||
'snapshot/+first=',
|
||||
null,
|
||||
'snapshot/+first=',
|
||||
]);
|
||||
assert.equal(fixture.state.inputs, 1);
|
||||
assert.equal(fixture.state.signatureErrors, 0);
|
||||
});
|
||||
|
||||
test('uncertain execution fails without replaying input or closing the Session', async (t) => {
|
||||
const fixture = await apiPeer(t, { turnStatus: 'recovery_blocked' });
|
||||
await assert.rejects(runSmoke(fixture), /recovery_blocked: fixture_failure/);
|
||||
assert.equal(fixture.state.inputs, 1);
|
||||
assert.equal(fixture.state.closed, false);
|
||||
});
|
||||
|
||||
test('an unsigned deployment is refused before creating any Session', async (t) => {
|
||||
const fixture = await apiPeer(t, { unsignedStatus: 200 });
|
||||
await assert.rejects(runSmoke(fixture), /Unsigned requests must be refused/);
|
||||
assert.equal(fixture.state.inputs, 0);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
if [ "${QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED:-}" != false ] || \
|
||||
[ -n "${QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY:-}${QWEN_HOME:-}" ]; then
|
||||
# Kubelet may widen PVC permissions for fsGroup before this process starts.
|
||||
for directory in "${QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY:?}" "${QWEN_HOME:?}"; do
|
||||
if [ -L "$directory" ]; then
|
||||
echo >&2 'Runtime private directory must not be a symlink.'
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -e "$directory" ]; then
|
||||
mkdir -m 700 -- "$directory"
|
||||
fi
|
||||
if [ ! -d "$directory" ] || \
|
||||
[ -n "$(find "$directory" ! -uid "$(id -u)" -print -quit)" ] || \
|
||||
[ -n "$(find "$directory" ! -type d ! -type f -print -quit)" ]; then
|
||||
echo >&2 'Runtime private state contains an unowned or unsupported entry.'
|
||||
exit 1
|
||||
fi
|
||||
find "$directory" -type d -exec chmod 700 {} +
|
||||
find "$directory" -type f -exec chmod 600 {} +
|
||||
done
|
||||
fi
|
||||
|
||||
exec "${JAVA_HOME:?}/bin/java" -jar /app/qwen-managed-agent-server.jar "$@"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -99,7 +99,7 @@ export interface WebShellTranscriptProps {
|
||||
blocks: readonly DaemonTranscriptBlock[];
|
||||
|
||||
theme?: WebShellTheme;
|
||||
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
|
||||
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn' | 'ru' | 'ru-ru';
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
chatMaxWidth?: number;
|
||||
|
||||
@@ -34,8 +34,8 @@ The facts below are from this slice's base, `main` at `e92b60a282`.
|
||||
2. **Identities are derived once and agree in both languages.** `automationRunId = arun_<sha256(scheduleId NUL occurrenceKey)>`. The authority and the Java store refuse a first `automation_run` revision whose id is not the derivation of its own `scheduleId` and `occurrenceKey`, so two claims of one occurrence collide on the record key and the second claimant reads the committed run — the structural dedup the H6a verification asked H6b for. The run's input is `<automationRunId>:input` (its turn id is the same), run command ids are `<automationRunId>:<revision>`, and the control plane derives `scheduleId` as `asch_<sha256(tenantId NUL Idempotency-Key)[0:32]>`, so a create retried after a crash between the Harness answer and the command row meets the same definition instead of minting a second one. Every definition mutation commits under the relayed operation id the control plane derives from the same `Idempotency-Key` (a name-based UUID), so a re-driven relay re-sends it: the authority replays the operation's originally committed revision from the journal — however the chain moved since — instead of committing the retried content over a newer revision, and the control plane records the replayed answer as the operation's result while its mirror never regresses past the revision it already tracks. A mutation honored without a revision (its content identical to the committed definition) commits an `operation.replayed` marker naming the honored revision, so its lost-answer retry answers the honored result instead of re-applying old content over the moved chain. Every replay verifies the operation's target and, for defines, its requested content; a retry under one identity naming another target or another content is refused `409 automation_operation_conflict` rather than answered with a different request's result.
|
||||
3. **A run binds to the live definition at the current revision.** A first `automation_run` revision must name a committed, non-terminal `schedule` of the same Session at exactly its current `definitionRevision`, with the same `sessionMode` and `targetSessionId`. A scanner that derived a slot from an older revision is refused and re-reads; the per-definition watermark (decision 7) keeps the slot covered or not regardless of which revision reads it.
|
||||
4. **The run's revision chain, persistent arm.** Revision 1 (`claim`): run `admitted`, execution `intent`, `dispatchId = automationRunId`, no runtime, no delivery. Revision 2 (`dispatch`): run `running`, execution `dispatch_started`, committed in one transaction with `input.accepted` (source `automation`, the envelope resource holding the turn text) and the generated `wake.requested`. Revision 3 (`settle`): run `settled` (turn completed), `failed` (turn error) or `cancelled` (turn cancelled), execution `settled`; an input the close path settled model-free ends `cancelled` with execution `not_started_proven`. Each step is one allowed move per state line (H0b), so a crash between any two leaves a chain the next step completes: a claim without its dispatch is re-driven by the scanner's `firing` row, and a settled turn without its settle revision is reconciled when the Session opens. A crash _inside_ the turn has no lawful proof either way: the run ends once, `failed` with execution `outcome_unknown` (the H0b pairing admits exactly that pair, widened here from recovery-blocked-only) — visible beside the definition instead of `running` forever, and the slot frees.
|
||||
5. **One Harness operation fires; it is complete before any effect and replay-safe.** `POST /session/:id/automations/operations` with `kind: fire_run` commits revisions 1 and 2 in the funnel's serial chain and answers the committed run; a replay answers the same run and completes a missing revision 2. The scanner inserts the occurrence row as `firing` before the call and marks it `fired` only after the answer, so a crash anywhere before that re-drives the same operation and never a second run. Every occurrence write names the lease its writer holds (owner and fence), so a scanner whose lease expired records and settles nothing; a manual run takes the same lease (`409 automation_busy` when a tick keeps it). A lost answer is re-driven with backoff (2s doubling, the shift clamped at 256 seconds — the 64 attempts total close to a four-hour window, not a 5-minute cap per attempt) and after 64 attempts the row is recorded `unknown`: visible, never re-driven, not counted as active. A replay of an occurrence whose run is already dispatched answers that run even after the definition moved or retired; a claim whose definition ended before its dispatch settles `cancelled` with execution `not_started_proven`. A `revision_stale` answer refreshes the mirror from the committed record and re-pins the claim to the live revision once before the row is recorded `skipped(revision_stale)`. Any other definitive 4xx answer settles the occurrence `skipped` with the route's typed code recorded as its reason — a verdict the request alone decides ends the slot — while store and transport faults answer a retryable 503 the backoff re-drives; the reason recorded is the answer's own code, `hosted_session_blocked` included. A run settles from the journal's own `turn.settled` event of its input. `define_schedule` and `retire_schedule` commit definition revisions the same way, replaying by the relayed operation id (decision 2). The verb route's connector re-runs the Workspace admission `submit` performs before every relay, attachment cached or not, so a grant revoked after the Session attached stops fires and definition mutations alike. When the connector's cache misses but the Session was attached before (its record carries `harnessBootId`), the relay re-attaches through the same takeover load a Turn uses, so a control-plane redeploy never strands every automated Session on `hosted_session_already_attached`.
|
||||
6. **Exactly one scanner claims, and the fence guards the ledger.** Each definition has one ledger row (`qwen_managed_automation_schedule`) with a lease (`lease_owner`, `lease_until`) and a monotonic `fence`; a scanner claims a row only when the lease is free or expired, bumps the fence, and writes every later ledger step with `WHERE lease_owner = ? AND fence = ?`. A scanner that lost its lease therefore cannot move the watermark or record occurrences, and could at most re-drive a Harness operation that is idempotent by the derived run id — the effect is guarded by the journal, the ledger by the fence. The scanner runs on a dedicated `managedAutomationScheduler` pool, never the one-thread default scheduler that also carries the message materializer, the turn-retry dispatch and the recovery ticks, because its blocking Harness calls would otherwise delay unrelated work by a request timeout at a time.
|
||||
5. **One Harness operation fires; it is complete before any effect and replay-safe.** `POST /session/:id/automations/operations` with `kind: fire_run` commits revisions 1 and 2 in the funnel's serial chain and answers the committed run; a replay answers the same run and completes a missing revision 2. The scanner inserts the occurrence row as `firing` before the call and marks it `fired` only after the answer, so a crash anywhere before that re-drives the same operation and never a second run. Every occurrence write names the lease its writer holds (owner and fence) and never checks the lease's expiry: a scanner whose lease lapsed can still record and settle until another claim moves the fence, and from then on records and settles nothing; a manual run takes the same lease (`409 automation_busy` when a tick keeps it). A lost answer is re-driven with backoff (2s doubling, the shift clamped at 256 seconds — the 64 attempts total close to a four-hour window, not a 5-minute cap per attempt) and after 64 attempts the row is recorded `unknown`: visible, never re-driven, not counted as active. A replay of an occurrence whose run is already dispatched answers that run even after the definition moved or retired; a claim whose definition ended before its dispatch settles `cancelled` with execution `not_started_proven`. A `revision_stale` answer refreshes the mirror from the committed record and re-pins the claim to the live revision once before the row is recorded `skipped(revision_stale)`. Any other definitive 4xx answer settles the occurrence `skipped` with the route's typed code recorded as its reason — a verdict the request alone decides ends the slot — while store and transport faults answer a retryable 503 the backoff re-drives; the reason recorded is the answer's own code, `hosted_session_blocked` included. A run settles from the journal's own `turn.settled` event of its input. `define_schedule` and `retire_schedule` commit definition revisions the same way, replaying by the relayed operation id (decision 2). The verb route's connector re-runs the Workspace admission `submit` performs before every relay, attachment cached or not, so a grant revoked after the Session attached stops fires and definition mutations alike. When the connector's cache misses but the Session was attached before (its record carries `harnessBootId`), the relay re-attaches through the same takeover load a Turn uses, so a control-plane redeploy never strands every automated Session on `hosted_session_already_attached`.
|
||||
6. **Exactly one scanner claims, and the fence guards the ledger.** Each definition has one ledger row (`qwen_managed_automation_schedule`) with a lease (`lease_owner`, `lease_until`) and a monotonic `fence`; a scanner claims a row only when the lease is free or expired, bumps the fence, and writes every later ledger step with `WHERE lease_owner = ? AND fence = ?`. A scanner whose lease another claim has taken therefore cannot move the watermark or record occurrences, and could at most re-drive a Harness operation that is idempotent by the derived run id — the effect is guarded by the journal, the ledger by the fence. The scanner runs on a dedicated `managedAutomationScheduler` pool, never the one-thread default scheduler that also carries the message materializer, the turn-retry dispatch and the recovery ticks, because its blocking Harness calls would otherwise delay unrelated work by a request timeout at a time.
|
||||
7. **Slots, watermark, late slots and catch-up.** A tick derives the slots of a definition in the window `(from, now]`, where `from` is the greater of the covered watermark and the arming instant, and never older than the lookback horizon (`qwen.managed-agent.automation.lookback`, default 24 h). A slot is _timely_ when `now − slot ≤ late-tolerance` (default 5 min) and _late_ otherwise. Timely slots fire. Late slots are missed under `none`; under `latest` the newest late slot fires as a catch-up run and the rest are recorded missed; under `bounded: N` the newest N fire oldest first. Every slot in the window gets a ledger row, and the watermark advances to the newest, so a later revision — or a later tick — never re-arms a covered slot (H6 decision 2). The result set is bounded (`max-slots-per-tick`, default 1,000) — the minute walk itself follows the lookback horizon, so raising it widens per-tick work, never trims it; a window beyond the result bound is cut at the oldest end and the cut is logged — unbounded catch-up has no path here.
|
||||
8. **Overlap is the Session's input-queue admission.** A `persistent` target runs one turn at a time, so an overlapping occurrence is one more pending input in the journal. `active` counts the definition's occurrences whose run is not terminal (`firing` rows included). `skip` fires only at `active = 0`, `queue_one` only at `active ≤ 1` (one running, one waiting in the journal), `allow` up to the concurrency quota (default 4); a dropped occurrence is recorded `skipped` with reason `overlap` or `count_limit`, never as a run. A target Session that is not `ACTIVE` records `skipped` with reason `session_not_active`. An open public Turn on the target suppresses every fire under any policy as `skipped` with reason `overlap`: the wake pump starts a queued automation input the moment the Session idles, while the dispatcher retries the Turn with backoff, so a recurring definition could otherwise starve the Turn indefinitely; the Turn goes first and the slot fires when it ends. The count joins the hosted Session store's record projection, so mutations answer `409 automation_unavailable` unless both `qwen.managed-agent.automation.enabled` and `qwen.managed-agent.session-store.enabled` are on.
|
||||
9. **Cron evaluation is pinned by a shared fixture.** Both languages enumerate minute instants in UTC, read each one's wall clock in the definition's zone, and match it with the H6a grammar under Vixie day semantics (either day field matches when neither is `*`), `7` as Sunday, and `N/step` as `N..max/step`. A wall-clock minute inside a DST gap fires once at the first instant after the gap; a minute repeated by a DST fold fires once, at its first instant. `managed-automation-slots-v1.fixtures.json` pins the tables — including gap and fold witnesses in the seven H6a anchor zones — and TypeScript and Java both replay them. The Java scanner is the production evaluator; the TypeScript twin exists so the semantics are a contract, not an implementation detail.
|
||||
|
||||
@@ -34,8 +34,8 @@ H6a 钉住了*什么可以提交*:以 `scheduleId` 为键、只增修订的定
|
||||
2. **身份只派生一次,两种语言一致。** `automationRunId = arun_<sha256(scheduleId NUL occurrenceKey)>`。authority 与 Java 存储拒绝 id 不是自身 `scheduleId` 与 `occurrenceKey` 派生值的首个 `automation_run` 修订,于是同一 occurrence 的两次 claim 在记录键上相撞,第二个 claim 方读取已提交的 run——这正是 H6a 验证要求 H6b 补上的结构性去重。run 的输入是 `<automationRunId>:input`(turn id 相同),run 的命令 id 为 `<automationRunId>:<revision>`,控制面以 `asch_<sha256(tenantId NUL Idempotency-Key)[0:32]>` 派生 `scheduleId`,因此在 Harness 答复与命令行之间崩溃后重试的 create 会遇到同一个定义,而不是铸造第二个。每个定义变更都在控制面从同一 `Idempotency-Key` 派生(基于名字的 UUID)并随 relay 传递的操作 id 下提交,于是被重新驱动的 relay 会重发同一个操作 id:authority 从日志重放该操作最初提交的修订——无论此后链如何前进——而不是把重试内容提交到更新的修订之上;控制面把重放答复记为该操作的结果,其镜像也绝不回退到已跟踪修订之前。内容与已提交定义完全一致、因而不追加修订的变更,会提交一条 `operation.replayed` 标记并记录所应答的修订,于是其答复丢失后的重试应答该已受理的结果,而不是把旧内容重新应用到已前进的链上。每次重放都校验操作的目标,以及 define 的请求内容;同一操作 id 指向另一个目标或另一份内容的重试以 `409 automation_operation_conflict` 拒绝,绝不拿别的请求的结果作答。
|
||||
3. **run 以当前修订绑定活的定义。** 首个 `automation_run` 修订必须命名同一 Session 内已提交、非终态、且恰好处于其当前 `definitionRevision` 的 `schedule`,并带相同的 `sessionMode` 与 `targetSessionId`。从旧修订推导出 slot 的扫描者会被拒并重新读取;每定义水位(决策 7)使 slot 是否已覆盖与读它的修订无关。
|
||||
4. **run 的修订链,persistent 分支。** 修订 1(`claim`):run `admitted`、execution `intent`、`dispatchId = automationRunId`,无 runtime、无 delivery。修订 2(`dispatch`):run `running`、execution `dispatch_started`,与 `input.accepted`(来源 `automation`,封装资源携带 turn 文本)及生成的 `wake.requested` 同一事务提交。修订 3(`settle`):run `settled`(turn 完成)、`failed`(turn 出错)或 `cancelled`(turn 取消),execution `settled`;被关闭路径无模型结算的输入以 `cancelled` 结束,execution 为 `not_started_proven`。每一步在每条状态线上至多移动一个允许的步(H0b),因此任意两步之间崩溃留下的链都能被下一步补全:没有 dispatch 的 claim 由扫描者的 `firing` 行重新驱动,没有结算修订的已结算 turn 在 Session 打开时对账。turn 内部*发生*的崩溃从日志侧无从取证:该 run 恰结算一次为 `failed` 且 execution `outcome_unknown`(H0b 配对在此恰放行这一对,自仅限 recovery_blocked 放宽)——可见地停在定义旁,而不是永远 `running`,名额立即释放。
|
||||
5. **一次 Harness 操作点火;它在任何副作用前完整,且可安全重放。** `POST /session/:id/automations/operations` 的 `kind: fire_run` 在 funnel 的串行链中提交修订 1 与 2 并返回已提交的 run;重放返回同一个 run,并补上缺失的修订 2。扫描者在调用前把 occurrence 行写为 `firing`,只在收到答复后标为 `fired`,于是在此之前任何位置崩溃都会重新驱动同一操作,而绝不是第二个 run。每次 occurrence 写入都携带写入者持有的租约(owner 与 fence),租约过期的扫描者无法记录或结算任何行;手动运行持有同一租约(tick 一直持有时返回 `409 automation_busy`)。丢失的答复以退避方式重新驱动(2 秒起倍增、位移钳制在 256 秒——约 64 次的总窗口接近四小时,而不是每次尝试限 5 分钟),64 次后该行记为 `unknown`:可见、不再重驱、不计入 active。run 已 dispatch 的 occurrence 被重放时,即使定义已修订或退役也返回该 run;定义在 dispatch 前结束的 claim 以 `cancelled`、execution `not_started_proven` 结算。`revision_stale` 答复会先从已提交记录刷新镜像,并把 claim 重新钉到当前修订重试一次,仍失败才记为 `skipped(revision_stale)`。其他确定性 4xx 答复一律以软路由的类型化错误码把 occurrence 记为 `skipped`、原因即该码——单凭请求自身即可判决的拒答就此了结本档,存储与传输故障应答的可重试 503 则由退避重新驱动;记入的原因就是应答自己的码,`hosted_session_blocked` 也不例外。run 依据其输入在日志中的 `turn.settled` 事件结算。`define_schedule` 与 `retire_schedule` 以同样方式提交定义修订,按 relay 传递的操作 id 重放(决策 2)。动词路由的 connector 在每次 relay 前重新执行与 `submit` 相同的 Workspace 准入校验,无论 attachment 是否已缓存,因此 Session 挂载后才被撤销的授权会同时挡住点火与定义变更。缓存未命中而 Session 此前已挂接(其记录携带 `harnessBootId`)时,relay 经由与 Turn 相同的 takeover load 重新挂接,控制面的每次重发版不会让全部自动化 Session 卡在 `hosted_session_already_attached` 上。
|
||||
6. **恰有一个扫描者 claim,代数守护台账。** 每个定义有一行台账(`qwen_managed_automation_schedule`),带租约(`lease_owner`、`lease_until`)与单调的 `fence`;扫描者只在租约空闲或过期时 claim,递增 fence,之后每一步台账写入都带 `WHERE lease_owner = ? AND fence = ?`。因此失去租约的扫描者无法推进水位或记录 occurrence,至多重新驱动一次按派生 run id 幂等的 Harness 操作——副作用由日志守护,台账由代数守护。扫描者跑在独立的 `managedAutomationScheduler` 线程池上,不占用也承载消息物化、Turn 重试派发与恢复 tick 的单线程默认调度器,否则每次被阻塞的 Harness 调用都会按请求超时拖慢与之无关的工作。
|
||||
5. **一次 Harness 操作点火;它在任何副作用前完整,且可安全重放。** `POST /session/:id/automations/operations` 的 `kind: fire_run` 在 funnel 的串行链中提交修订 1 与 2 并返回已提交的 run;重放返回同一个 run,并补上缺失的修订 2。扫描者在调用前把 occurrence 行写为 `firing`,只在收到答复后标为 `fired`,于是在此之前任何位置崩溃都会重新驱动同一操作,而绝不是第二个 run。每次 occurrence 写入都携带写入者持有的租约(owner 与 fence),但从不检查租约是否过期:租约已过期的扫描者在另一次 claim 推进 fence 之前仍能记录和结算,此后便无法记录或结算任何行;手动运行持有同一租约(tick 一直持有时返回 `409 automation_busy`)。丢失的答复以退避方式重新驱动(2 秒起倍增、位移钳制在 256 秒——约 64 次的总窗口接近四小时,而不是每次尝试限 5 分钟),64 次后该行记为 `unknown`:可见、不再重驱、不计入 active。run 已 dispatch 的 occurrence 被重放时,即使定义已修订或退役也返回该 run;定义在 dispatch 前结束的 claim 以 `cancelled`、execution `not_started_proven` 结算。`revision_stale` 答复会先从已提交记录刷新镜像,并把 claim 重新钉到当前修订重试一次,仍失败才记为 `skipped(revision_stale)`。其他确定性 4xx 答复一律以软路由的类型化错误码把 occurrence 记为 `skipped`、原因即该码——单凭请求自身即可判决的拒答就此了结本档,存储与传输故障应答的可重试 503 则由退避重新驱动;记入的原因就是应答自己的码,`hosted_session_blocked` 也不例外。run 依据其输入在日志中的 `turn.settled` 事件结算。`define_schedule` 与 `retire_schedule` 以同样方式提交定义修订,按 relay 传递的操作 id 重放(决策 2)。动词路由的 connector 在每次 relay 前重新执行与 `submit` 相同的 Workspace 准入校验,无论 attachment 是否已缓存,因此 Session 挂载后才被撤销的授权会同时挡住点火与定义变更。缓存未命中而 Session 此前已挂接(其记录携带 `harnessBootId`)时,relay 经由与 Turn 相同的 takeover load 重新挂接,控制面的每次重发版不会让全部自动化 Session 卡在 `hosted_session_already_attached` 上。
|
||||
6. **恰有一个扫描者 claim,代数守护台账。** 每个定义有一行台账(`qwen_managed_automation_schedule`),带租约(`lease_owner`、`lease_until`)与单调的 `fence`;扫描者只在租约空闲或过期时 claim,递增 fence,之后每一步台账写入都带 `WHERE lease_owner = ? AND fence = ?`。因此租约已被另一次 claim 接手的扫描者无法推进水位或记录 occurrence,至多重新驱动一次按派生 run id 幂等的 Harness 操作——副作用由日志守护,台账由代数守护。扫描者跑在独立的 `managedAutomationScheduler` 线程池上,不占用也承载消息物化、Turn 重试派发与恢复 tick 的单线程默认调度器,否则每次被阻塞的 Harness 调用都会按请求超时拖慢与之无关的工作。
|
||||
7. **slot、水位、迟到 slot 与 catch-up。** 一次 tick 推导定义在窗口 `(from, now]` 内的 slot,其中 `from` 是已覆盖水位与布防时刻中较大者,且不早于回看地平线(`qwen.managed-agent.automation.lookback`,默认 24 小时)。`now − slot ≤ late-tolerance`(默认 5 分钟)的 slot 为*及时*,否则为*迟到*。及时 slot 点火。迟到 slot 在 `none` 下记为 missed;在 `latest` 下最新的一个迟到 slot 作为 catch-up run 点火、其余记为 missed;在 `bounded: N` 下最新的 N 个由旧到新点火。窗口内每个 slot 都有一行台账,水位推进到最新,因此后续修订——或后续 tick——绝不会重新布防已覆盖的 slot(H6 决策 2)。返回集有界(`max-slots-per-tick`,默认 1,000)——逐分钟的遍历本身跟随回看地平线,因此放大回看会扩大每 tick 的工作量而不是削减排程;窗口超出返回上界时从最旧一端截断并记录日志——这里不存在无限补跑的路径。
|
||||
8. **overlap 是 Session 输入队列的准入。** `persistent` 目标一次运行一个 turn,因此重叠的 occurrence 只是日志里多一个待处理输入。`active` 统计该定义中 run 非终态的 occurrence(含 `firing` 行)。`skip` 仅在 `active = 0` 时点火,`queue_one` 仅在 `active ≤ 1` 时(一个在跑、一个在日志中等待),`allow` 最多到并发配额(默认 4);被丢弃的 occurrence 以原因 `overlap` 或 `count_limit` 记为 `skipped`,绝不记为 run。目标 Session 不是 `ACTIVE` 时记为 `skipped`,原因 `session_not_active`。目标上有未结束的公开 Turn 时,在任意 overlap 策略下一律记 `skipped`、原因 `overlap`:Session 一空闲,唤醒泵立即启动排队的自动化输入,而派发器以退避重试该 Turn——周期性定义可以因此把该 Turn 饿死;Turn 先行,结束后 slot 再点火。该计数关联托管 Session 存储的记录投影,因此只有 `qwen.managed-agent.automation.enabled` 与 `qwen.managed-agent.session-store.enabled` 同时开启时,变更类请求才不会返回 `409 automation_unavailable`。
|
||||
9. **cron 求值由共享 fixture 钉住。** 两种语言都按 UTC 枚举分钟时刻,在定义的时区读取其墙上时间,并按 H6a 文法在 Vixie 日语义下匹配(两个日字段都不是 `*` 时任一匹配即可)、`7` 为周日、`N/step` 为 `N..max/step`。落在 DST 空隙内的墙上分钟在空隙后的第一个时刻点火一次;被 DST 折叠重复的分钟只在其第一个时刻点火一次。`managed-automation-slots-v1.fixtures.json` 钉住这些表——包括七个 H6a 锚点时区中的空隙与折叠见证——TypeScript 与 Java 都回放。Java 扫描者是生产求值器;TypeScript 孪生的存在是为了让语义成为契约而非实现细节。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Daemon requests and automatic recovery
|
||||
|
||||
[English](2026-10-09-daemon-request-recovery.md) | [简体中文](2026-10-09-daemon-request-recovery.zh-CN.md)
|
||||
|
||||
## Status and scope
|
||||
|
||||
Both stages are implemented on this branch and described separately for review. Stage 1 consolidates daemon HTTP requests in the SDK without changing connection policy. Stage 2 pauses business traffic while the daemon is unavailable and automatically restores it through one recovery probe loop per provider. Native WebSockets share the stage 2 connection gate. No server routes or ownership rules change. The gate is enabled by Web Shell; SDK consumers retain their existing connection policy.
|
||||
|
||||
## Problem and current behavior
|
||||
|
||||
Web Shell mixes SDK calls with direct HTTP calls. Session reconnection and heartbeat depend on an active session, so a page waiting for its first prompt can retain a connected status after the daemon exits. Independent polling and reconnect loops then generate repeated failures. Capabilities describe supported features and are not evidence of current availability.
|
||||
|
||||
## Stage 1: SDK request entry
|
||||
|
||||
Add a raw native REST request entry to DaemonClient. It uses the client's fetch implementation and credential, preserves request headers, body, cancellation, keepalive and response handling, and supports a connect-phase timeout override. Return the Response so existing JSON validation, HTTP error messages and SSE parsing remain at their current call sites. REST-only routes must not be translated through ACP. Existing typed SDK methods retain their transport selection. Ordinary native REST calls retain the SDK connection deadline; a supplied signal owns cancellation and any caller-specific deadline, preserving long prewarm requests and SSE bodies. The older peer SDK fallback uses a 30-second connection deadline when no signal is supplied.
|
||||
|
||||
Migrate workspace filesystem, goals and scheduled tasks; agent roster and session-agent requests and streams; Local Control; session detach; startup and candidate-credential probes; pairing exchange; remote-workspace proxies; and daemon-side desktop-relay setup. Prefer a shared SDK client where the provider already owns the relevant connection. Preserve compatibility with older supported SDKs through one compatibility adapter if required; do not scatter feature checks across callers.
|
||||
|
||||
Requests keep their existing ownership: health and capabilities are process-global; qualified workspace routes retain their selected or persisted workspace selector; session routes retain live-session ownership; legacy routes remain legacy-primary. Remote-workspace proxy calls authenticate to the serving daemon and carry the target token separately. Candidate authentication and pairing must remain independent of the current connection's credential.
|
||||
|
||||
CDN assets, service-worker navigation and asset caching, and the separate desktop-relay service are excluded. Progress uploads already enter the SDK through XMLHttpRequest; Web Shell checks availability at the upload send point and reports transport failures without changing the SDK upload protocol.
|
||||
|
||||
## Stage 2: Availability and recovery
|
||||
|
||||
A shared daemon-connection state controls business requests independently of session status. On a transport failure, pause further business traffic and coalesce a connection check. Once unavailable, independent business polls, heartbeats and reconnect loops must not send network requests. Preserve cached lists, transcript and unsent drafts. Show a persistent connection warning across the main views, with an explicit retry action inside the visible fullscreen or modal artifact panel when it owns the view. Terminal and local-file bridge connections wait for shared recovery; a terminal opened while offline shows one localized waiting notice. Explicit terminal release waits independently of the panel lifetime and uses the release-only route without creating a PTY. QR pairing refresh stops on outage and requires explicit retry after recovery. Voice and Live Host capture stop on disconnection and require a user action to restart; recovery never replays audio.
|
||||
|
||||
Each DaemonWorkspaceProvider owns one authenticated lightweight native REST health probe loop for its active connection, shared by all panels beneath it. Separate providers targeting the same URL and credential retain independent controllers and probe loops; endpoint-wide deduplication is not implemented. REST adapters and session-agent streams prefer the controller bound to their SDK client; endpoint lookup is a fallback for clients without an owner binding. The standalone application uses one focused provider. Retry delays are 5 seconds, 15 seconds and then 30 seconds, scheduled after completion with a bounded timeout. Ordinary checks respect pending retry delays and authentication suspension. A foreground transition or explicit retry can request an immediate coalesced probe. A successful probe revalidates capabilities and authentication before reopening business traffic. Resume reads and session attachment, never replay prompt submissions or other writes.
|
||||
|
||||
Business validation errors, missing sessions or workspaces, and intentional cancellation do not prove daemon failure. A raw TypeError only proves transport failure when observed at a fetch boundary; an unclassified consumer TypeError requests diagnosis without suspending healthy traffic. Authentication rejection pauses automatic probing in a separate state until credentials change or the user retries. Request deadlines, HTTP gateway errors and an ended stream request a coalesced check; they do not suspend a healthy connection merely because a business route failed. Old requests and probes cannot change a replacement connection or restore an obsolete state. Request generations also prevent late pre-outage failures from suspending a recovered connection. Disposal cancels probes and recovery waiters, including multiple providers and StrictMode remounts.
|
||||
|
||||
A paused connection does not certify cached live activity indefinitely. After a sustained 90-second outage (three live-state retry windows), expire live-session authority locally without issuing business requests; a shorter outage cancels that expiry. Keep the session catalog itself cached. Cold session lists and the status dashboard show unavailable feedback rather than an empty result or an endless initial load, and connection repair remains accessible. A managed view keeps the warning when its artifact panel is not mounted.
|
||||
|
||||
Connection recovery invalidates pending status reads independently of in-flight settings writes. Preserve the result of an already-issued settings update and never replay it. Host-handled local slash commands and local model-policy feedback still run before the network submission gate. Voice capture retains its raw error vocabulary; its UI formatter localizes the service-unavailable reason once.
|
||||
|
||||
## Metrics and limits
|
||||
|
||||
The exported transcript includes the shared Web Shell translations and availability consumers. With the same dependencies, the renderer grows from 2,048,235 bytes on merged main `65051590d9` to 2,050,022 bytes on this branch (+1,787). Both builds have the same 2,174 inputs and unchanged third-party sources. The JavaScript budget rises from 2,050,000 to 2,052,000 bytes, leaving 1,978 bytes of headroom; the forbidden-dependency guard remains unchanged.
|
||||
|
||||
Actual failed requests remain failures. Operations blocked locally are not HTTP requests. Recovery probes use `/health`, followed by `/capabilities` after health succeeds, and must be accounted for by endpoint so the host can account for them separately from business API success. The repository investigation did not find a generic host HTTP-success reporter, so host metric filtering cannot be claimed without integration evidence. The initial failures needed to discover an outage and already-in-flight requests cannot be eliminated. A host-provided transport may perform its own internal retries before returning an error; this gate cannot suppress network activity hidden inside that transport.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
Stage 1 verifies native REST selection with custom ACP transports, explicit fetch injection, authentication and header merging, encoded workspace paths, empty and prefixed base URLs, cancellation, keepalive, response errors and a long-lived SSE body. Run focused package tests, build and typecheck. Browser checks use both a real daemon and deterministic mocks where useful, with each evidence source labelled.
|
||||
|
||||
Stage 2 verifies a session-less page, active session and multiple panels during stop and restart. After in-flight requests settle, unavailable business traffic must remain zero and only the provider’s shared recovery loop may issue probes. Restart restores reads and session attachment without losing drafts or replaying writes. Check false-positive classification, stale responses, unmount cleanup and custom transports.
|
||||
|
||||
## Open integration questions
|
||||
|
||||
Confirm how the deployment controller counts idle activity and how the host instruments health probes. Confirm restart semantics when the old session no longer exists; use the current session recovery behavior rather than creating or sending work implicitly.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Daemon 请求与自动恢复
|
||||
|
||||
[English](2026-10-09-daemon-request-recovery.md) | [简体中文](2026-10-09-daemon-request-recovery.zh-CN.md)
|
||||
|
||||
## 状态与范围
|
||||
|
||||
本分支已实现两个阶段,分开描述以便评审。第一阶段将 daemon HTTP 请求收口到 SDK,不改变连接策略。第二阶段在 daemon 不可用时暂停业务流量,通过每个 Provider 共享的恢复探测循环自动恢复。原生 WebSocket 共用第二阶段的连接门禁。不改变服务端路由及其归属规则。门禁由 Web Shell 启用,其他 SDK 使用方保留现有连接策略。
|
||||
|
||||
## 问题与当前行为
|
||||
|
||||
Web Shell 混用 SDK 调用与直接 HTTP 调用。会话重连和心跳依赖已有会话,因此等待首条 prompt 的页面可能在 daemon 退出后继续保留已连接状态。独立轮询与重连循环随后产生重复失败。Capabilities 描述支持的功能,不能证明当前可用性。
|
||||
|
||||
## 第一阶段:SDK 请求入口
|
||||
|
||||
为 DaemonClient 增加原生 REST 请求入口。使用客户端的 fetch 实现与凭证,保留请求 headers、body、取消、keepalive 和响应处理,并支持连接阶段超时覆盖。返回 Response,让现有 JSON 校验、HTTP 错误文案和 SSE 解析继续留在原调用点。REST 专用路由不能转换为 ACP 请求。现有 SDK 类型化方法保持原来的 transport 选择。普通原生 REST 调用保留 SDK 的连接阶段期限;传入 signal 时,由其负责取消与调用方的专用期限,保留长时间预热请求和 SSE body。旧 peer SDK 的兼容路径在未传 signal 时使用 30 秒连接期限。
|
||||
|
||||
迁移 workspace 文件系统、goals 和定时任务;Agent 名录、会话 Agent 请求及事件流;Local Control;会话 detach;启动和候选凭证探测;配对交换;远程 workspace 代理;以及 desktop-relay 的 daemon 端准备请求。如果 Provider 已持有对应连接,优先复用其 SDK 客户端。必要时通过一个兼容适配器支持已声明兼容的旧 SDK,不在各调用点分散添加特性检查。
|
||||
|
||||
请求保持现有归属:health 和 capabilities 属于进程全局;限定 workspace 的路由保留选定或持久化 workspace 的选择器;会话路由保留 live-session-owner 归属;旧路由仍归属 legacy-primary。远程 workspace 代理使用提供代理服务的 daemon 凭证,目标 token 单独传递。候选认证与配对必须独立于当前连接凭证。
|
||||
|
||||
排除 CDN 资源、Service Worker 的导航和资源缓存,以及独立的 desktop-relay 服务。带进度上传已经通过 XMLHttpRequest 进入 SDK;Web Shell 在上传发送点检查可用性并报告传输失败,不改变 SDK 的上传协议。
|
||||
|
||||
## 第二阶段:可用性与恢复
|
||||
|
||||
共享 daemon 连接状态独立于会话状态控制业务请求。发生传输失败后暂停后续业务流量,并合并连接检查。确认不可用后,各业务轮询、心跳和重连循环不得继续发送网络请求。保留列表缓存、聊天记录和未发送草稿,在主要视图展示持续的连接提示,并提供手动重试;全屏或模态 artifact 面板占据当前视图时,提示和重试位于该可见面板内。终端和本地文件桥等待共享恢复,离线时首次打开的终端显示一次本地化等待提示;显式终端释放独立于面板生命周期等待恢复,只使用释放专用路径,不创建 PTY。二维码配对刷新在断线后停止,恢复后须手动重试。语音和 Live Host 采集在断线后停止,须由用户重新开启,恢复过程不重放音频。
|
||||
|
||||
每个 DaemonWorkspaceProvider 为其活动连接维护一个带认证的轻量原生 REST 健康探测循环,下属所有面板共用该循环。指向同一 URL 和凭证的不同 Provider 仍各自维护控制器与探测循环,当前未实现 endpoint 级去重。REST 适配器和会话 Agent 事件流优先使用其 SDK client 绑定的控制器;仅在 client 未绑定归属时才按 endpoint 查找。独立应用使用一个聚焦的 Provider。重试间隔为 5 秒、15 秒,随后 30 秒;每次结束后再安排下一次,且有超时上限。普通检查遵守待执行的退避和认证暂停;页面回到前台或用户手动重试时可触发一次合并的立即探测。探测成功后重新验证 capabilities 与认证,再开放业务流量。恢复读取和会话附着,绝不重放 prompt 或其他写操作。
|
||||
|
||||
业务校验错误、会话或 workspace 不存在、主动取消都不能证明 daemon 故障。原始 TypeError 只有在 fetch 边界观察到时才作为传输故障处理;消费方上报的未分类 TypeError 仅请求诊断,不暂停健康连接。认证拒绝进入独立状态并暂停自动探测,等待凭证更新或用户手动重试。请求超时、HTTP 网关错误和事件流结束触发合并检查,不能仅因业务路由失败就暂停健康连接。旧请求与探测不得修改替换后的连接,也不能恢复过期状态。请求代次还能防止故障前的迟到失败重新关闭已经恢复的连接。销毁时取消探测与恢复等待,覆盖多个 Provider 与 StrictMode 重挂载。
|
||||
|
||||
暂停连接不能让缓存的实时活动状态永久有效。持续离线 90 秒(三个实时状态重试窗口)后,在本地使实时会话状态失效,不发送业务请求;短暂断线恢复会取消该过期任务。会话目录本身继续保留缓存。未加载过的会话列表和状态面板显示不可用提示,不能误报为空结果或持续显示首次加载,连接修复入口仍可使用。Managed 视图没有挂载 artifact 面板时仍保留连接提示。
|
||||
|
||||
恢复连接时,独立使在途状态读取失效,不影响已发出的设置写操作;保留其结果,绝不重放写入。宿主处理的本地斜杠命令和本地模型策略提示仍在网络提交门禁前执行。语音采集保留原始错误词汇,只由界面格式化器对服务不可用原因做一次本地化。
|
||||
|
||||
## 指标与限制
|
||||
|
||||
导出的聊天记录包含 Web Shell 共享翻译表和可用性消费代码。在相同依赖下,渲染器从合入主线 `65051590d9` 的 2,048,235 字节增长至本分支的 2,050,022 字节(增加 1,787 字节)。两次构建均有相同的 2,174 个输入,第三方源码未变。JavaScript 预算从 2,050,000 调整至 2,052,000 字节,保留 1,978 字节余量;禁止依赖的检查保持不变。
|
||||
|
||||
实际发出的失败请求仍如实记录为失败。本地阻止的操作不属于 HTTP 请求。恢复探测使用 `/health`,健康检查成功后再请求 `/capabilities`,必须按 endpoint 统计,使宿主能与业务 API 成功率分开统计。仓库调查未找到通用的宿主 HTTP 成功率上报入口,因此没有集成证据时不能声称已完成宿主指标过滤。用于发现故障的首批失败和已在途请求无法消除。宿主提供的 transport 可能在返回错误之前自行重试;门禁无法拦截该 transport 内部隐藏的网络活动。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
第一阶段验证自定义 ACP transport 下的原生 REST 选择、显式 fetch 注入、认证与 headers 合并、编码后的 workspace 路径、空及带前缀 base URL、取消、keepalive、响应错误以及长时间 SSE body。运行相关包的定向测试、构建和类型检查。浏览器检查按需使用真实 daemon 与确定性模拟后端,并标明各证据来源。
|
||||
|
||||
第二阶段验证无会话页面、活动会话和多面板的停止与重启。在途请求结束后,不可用期间业务流量必须保持为零,仅该 Provider 共享的恢复循环可以发出探测。重启后恢复读取和会话附着,不能丢失草稿或重放写操作。检查误判分类、过期响应、卸载清理和自定义 transport。
|
||||
|
||||
## 待确认的集成问题
|
||||
|
||||
确认部署控制器如何计算空闲活动,以及宿主如何采集健康探测。确认旧会话已不存在时的重启语义;复用现有会话恢复行为,不隐式创建或发送任务。
|
||||
@@ -510,8 +510,9 @@ Integration regressions proved that model request preparation failures preserve
|
||||
|
||||
The machine-readable reports, manifests and retained failed diagnostics are under `.qwen/investigations/g3-step3-implementation/`; the current consolidated report is `packaged-verification.md`. These five packaged gates do not prove the whole matrix above. Since [replaceable-Harness D11](2026-10-02-hosted-replaceable-harness.md#d11--residual-gates-step-3-in-ci-the-model-round-arm-and-q2s-binding-half), they are CI steps of the `hosted-failover-e2e` job, which runs apart from the Hosted MySQL verify (see D11), next to a model-round arm that packages the main observations of B1 for a no-tool Session and of B2, though not their attempt and stream identities, the live SSE view or the admission count.
|
||||
|
||||
#14481's second step adds seven packaged fault arms to the same job. Five layer
|
||||
one fault on the Allow path: three crash the replacement Harness after a
|
||||
#14481's packaged gates add fourteen fault arms to the same job. Seven landed with
|
||||
the second step. Five layer one fault on the Allow path: three crash the
|
||||
replacement Harness after a
|
||||
durable A5 barrier — once the Allow decision is recorded (A5's decision row),
|
||||
once the approved execution reaches `PREPARED` while the Runtime Broker's
|
||||
`:start` is held (A5a's partial prepare), and once the approved result commits
|
||||
@@ -522,24 +523,49 @@ admission must retry to one Turn row (C4). All five leave the original Action,
|
||||
prepare key, digest and Runtime identity in place, start a third generation,
|
||||
and require exactly one settled execution, one file effect and one terminal;
|
||||
the result-commit arm additionally reuses the saved request and retracts the
|
||||
published prefix. Two more cover the next barriers: a committed
|
||||
published prefix. Five more cover the next barriers: a committed
|
||||
`turn.settled` whose reply is lost must be projected once after replacement
|
||||
with zero inference or tool calls (no second store commit is required), and
|
||||
the surviving public text must still be the recovered answer (C1); and a
|
||||
refused terminal write must leave the payable checkpoint to be compensated
|
||||
exactly once by the replacement, with the recovered answer still visible
|
||||
(C2). A refused cleanup release followed by another crash (C3) stays focused:
|
||||
the packaged arm reproduced a control-plane gap where a delayed replacement
|
||||
receives no cleanup retry or adoption, so fixing that path is separate. A
|
||||
lost takeover-load reply (A4's load half) stays focused: the packaged arm
|
||||
showed the redrive's active handoff leaves the original Turn RUNNING instead
|
||||
of projecting its terminal, so fixing that recovery path is separate from
|
||||
this acceptance slice. Malformed or foreign refs, snapshot overflow, mixed
|
||||
calls and rewritten inputs across a replacement (A3), Hook/MCP/child
|
||||
(C2). A refused cleanup release followed by another crash (C3) is packaged
|
||||
too: once the Turn is terminal and its generation is dead, the control plane
|
||||
re-drives the owed cleanup through a cleanup-only load until the replacement
|
||||
confirms it ([cleanup debt retry](2026-10-28-hosted-cleanup-debt-retry.md)).
|
||||
A lost takeover-load reply (A4's load half) is packaged as well: the replacement
|
||||
loses the reply to its first drive load once (a test-gated hook), the control
|
||||
plane redrives the same load into the now-active driver, and the original Turn
|
||||
must still project exactly one terminal with the recovered answer — the redrive
|
||||
restates the active attachment rather than wedging the Turn. An earlier observed
|
||||
stuck-RUNNING variant did not reproduce on the current tree, so the arm gates the
|
||||
handoff as a regression rather than marking an open gap. A crash between the
|
||||
main stream's durable Finished marker and the assistant commit (B3a) is
|
||||
packaged too: a test-gated hook holds the replacement inside that window once
|
||||
its post-tool round has recorded Finished, the runner crashes it there, and the
|
||||
next generation must reissue the saved request deep-equal and project one
|
||||
terminal with the recovered answer rather than compensate an already-committed
|
||||
output. A crash in the mixed-sibling approval gap (A3's native half) is
|
||||
packaged as well: a two-call batch is approved one sibling at a time, a
|
||||
test-gated hook holds the replacement on the first approval it asks after the
|
||||
earlier sibling is decided, the runner crashes it there, and the next
|
||||
generation must re-hang the original pending Action with its original deadline,
|
||||
require that sibling's own Allow rather than inherit the earlier one, leave the
|
||||
decided sibling un-re-asked, and settle both calls once each in their original
|
||||
order ([A3 mixed-sibling approval](2026-10-31-hosted-a3-mixed-sibling-approval.md)).
|
||||
A crash after several earlier tool rounds (B3) is packaged too: a multi-round
|
||||
fixture runs two earlier tools, the replacement is crashed while a later post-tool
|
||||
request is held, and the next generation must resume the saved logical round and
|
||||
restore the turn cost snapshot and per-model token ledger rather than restart them,
|
||||
with each earlier execution occurring once and the later request reissued deep-equal
|
||||
([B3 round and cost budget](2026-10-31-hosted-b3-round-cost-budget.md)). "Cost budget"
|
||||
here is the TurnBudget snapshot plus the token ledger, not a cumulative spend gate.
|
||||
Malformed or foreign refs, snapshot overflow, the rewritten-input and
|
||||
Hook-no-rerun half of A3 (a commandStep follow-up, since a native continuation
|
||||
refuses Hook/MCP sessions), Hook/MCP/child
|
||||
uncertainty (A6), the attempt and stream identities plus the live SSE view
|
||||
(B2), the round and cost budgets (B3), the crash between stream Finished and
|
||||
the assistant commit (B3a), and the mixed-version reader/bundle closure (R2)
|
||||
stay focused or integration evidence. Full-owner restart, real-provider
|
||||
(B2), and the mixed-version reader/bundle
|
||||
closure (R2) stay focused or integration evidence. Full-owner restart, real-provider
|
||||
acceptance and a real mixed-version cluster remain separate. Q2's binding
|
||||
half has the D11 frozen control-plane arm for a woken former Spring's stale
|
||||
claim and a store test of the bind guards; two live control planes remain a
|
||||
|
||||
@@ -416,7 +416,7 @@ Allow 保留原 Action、prepare key、request digest 和 Runtime binding,产
|
||||
|
||||
机器可读报告、manifest 和保留的失败诊断位于 `.qwen/investigations/g3-step3-implementation/`,当前汇总报告为 `packaged-verification.md`。这五种打包门禁不证明上述整个矩阵。自[可替换 Harness 设计 D11](2026-10-02-hosted-replaceable-harness.zh-CN.md#d11--残留门禁step-3-进-ci模型轮支路q2-的-binding-半边) 起,它们是 `hosted-failover-e2e` 任务的 CI 步骤,该任务独立于 Hosted MySQL 的 verify 运行(见 D11);同一任务里还有一条模型轮支路,把无工具 Session 的 B1 与 B2 的主要观察项做成打包门禁,但不覆盖它们的 attempt 与 stream 身份、实时 SSE 视图以及准入次数。
|
||||
|
||||
#14481 的第 2 步在同一任务里新增七条打包故障支路。五条在 Allow 路径上叠加一层故障:三条在持久 A5 屏障之后崩溃替代 Harness——一条在 Allow 决策落库后(A5 的决策行),一条在批准执行到达 `PREPARED`、Runtime Broker 的 `:start` 被扣住时(A5a 的部分 prepare),还有一条在批准结果已提交、post-tool 模型轮被扣住时(A5 的 result commit 屏障)——另两条在提交后丢弃控制面的回复:决策回复丢失后,同一 `Idempotency-Key` 重试必须落到同一条记录,只产生一个 `ACTION_RESPONSE` operation(A4 的决策半边);Turn 准入回复丢失后,重试只产生一行 Turn(C4)。五条都保留原 Action、prepare key、digest 与 Runtime 身份,启动第三代 Harness,并要求一次已结算执行、一次文件效果和一个终态;result-commit 支路还要求重发复用同一个已保存请求,并撤回已发布的片段。另外两条覆盖接下来的屏障:`turn.settled` 已提交但回复丢失时,替换后必须投影一次终态(不要求第二次 store 提交),且不产生推理或工具调用,公开文本仍必须是恢复出的答案(C1);终态写入被拒时,可付 checkpoint 必须由替换方恰好补偿一次,且恢复出的答案仍可见(C2)。清理释放被拒、随后再次崩溃(C3)仍保持定向覆盖:打包支路复现了控制面缺口——延迟启动的替换方收不到 cleanup 重试或 adoption——修复该路径与本验收切片分开。接管 load 回复丢失(A4 的 load 半边)仍保持定向覆盖:打包支路显示重发的 active handoff 没有投影终态,原 Turn 停在 RUNNING,修复该恢复路径与本验收切片分开。损坏/外部 ref、快照超限、替换后的混合调用与重写输入(A3)、Hook/MCP/child 不确定性(A6)、attempt 与 stream 身份及实时 SSE 视图(B2)、轮次与成本预算(B3)、stream Finished 与 assistant 提交之间的崩溃(B3a),以及混版本 reader/bundle 闭包(R2)仍由定向或集成证据覆盖。两侧 owner 重启、真实 provider 和真实混版本集群仍独立处理。Q2 的 binding 半边有 D11 的冻结控制面支路覆盖醒来的旧 Spring 的过期 claim,并有 store 测试钉住 bind 守卫;两个存活控制面仍属后续阶段。
|
||||
#14481 的打包门禁在同一任务里新增十四条故障支路,其中七条随第 2 步落地。五条在 Allow 路径上叠加一层故障:三条在持久 A5 屏障之后崩溃替代 Harness——一条在 Allow 决策落库后(A5 的决策行),一条在批准执行到达 `PREPARED`、Runtime Broker 的 `:start` 被扣住时(A5a 的部分 prepare),还有一条在批准结果已提交、post-tool 模型轮被扣住时(A5 的 result commit 屏障)——另两条在提交后丢弃控制面的回复:决策回复丢失后,同一 `Idempotency-Key` 重试必须落到同一条记录,只产生一个 `ACTION_RESPONSE` operation(A4 的决策半边);Turn 准入回复丢失后,重试只产生一行 Turn(C4)。五条都保留原 Action、prepare key、digest 与 Runtime 身份,启动第三代 Harness,并要求一次已结算执行、一次文件效果和一个终态;result-commit 支路还要求重发复用同一个已保存请求,并撤回已发布的片段。另外五条覆盖接下来的屏障:`turn.settled` 已提交但回复丢失时,替换后必须投影一次终态(不要求第二次 store 提交),且不产生推理或工具调用,公开文本仍必须是恢复出的答案(C1);终态写入被拒时,可付 checkpoint 必须由替换方恰好补偿一次,且恢复出的答案仍可见(C2)。清理释放被拒、随后再次崩溃(C3)也已打包:Turn 进入终态且其 Harness 代际已死之后,控制面通过 cleanup-only load 重试欠付清理,直到替换方确认([清理欠债重试](2026-10-28-hosted-cleanup-debt-retry.zh-CN.md))。接管 load 回复丢失(A4 的 load 半边)也已打包:替换方第一次 drive load 的回复被丢失一次(测试门控钩子),控制面把同一 load 重发进已启动的 active driver,原 Turn 仍须恰好投影一个终态并保留恢复出的答案——重发原样重述 active attachment,而不是把 Turn 卡死。早先观察到的卡 RUNNING 变体在当前代码树上复现不出,因此该臂作为回归门禁守住这条交接,而非标记一个未决缺口。主流 Finished 持久标记与 assistant 提交之间的崩溃(B3a)也已打包:测试门控钩子在替换方的 post-tool 轮记录 Finished 之后、assistant 提交之前把它扣在该窗口内,runner 在那里崩溃它,下一代必须 deep-equal 重发已保存请求并投影一个终态、保留恢复出的答案,而不是补偿一个已提交的输出。混合兄弟审批裂缝中的崩溃(A3 的 native 半边)也已打包:一个两调用批次按兄弟逐个审批,测试门控钩子在较早兄弟已决之后、替换方第一次询问审批时把它扣住,runner 在那里崩溃它,下一代必须用原 deadline 重挂原 pending Action、要求该兄弟自己的 Allow 而非继承较早的 Allow、不重问已决兄弟,并按原顺序各结算两个调用一次([A3 混合兄弟审批](2026-10-31-hosted-a3-mixed-sibling-approval.zh-CN.md))。若干先前工具轮之后的崩溃(B3)也已打包:一个多轮夹具跑两个先前工具,替换方在较后的 post-tool 请求被扣住时崩溃,下一代必须续跑已保存的逻辑轮次、恢复回合成本快照与每模型 token 账本而非重启它们,每个先前执行恰好一次、较后请求 deep-equal 重发([B3 轮次与成本预算](2026-10-31-hosted-b3-round-cost-budget.zh-CN.md))。此处「成本预算」指 TurnBudget 快照加 token 账本,而非累计花费闸门。损坏/外部 ref、快照超限、A3 的改写输入与 Hook 不重跑半边(commandStep 后续,因为 native continuation 拒绝 Hook/MCP 会话)、Hook/MCP/child 不确定性(A6)、attempt 与 stream 身份及实时 SSE 视图(B2),以及混版本 reader/bundle 闭包(R2)仍由定向或集成证据覆盖。两侧 owner 重启、真实 provider 和真实混版本集群仍独立处理。Q2 的 binding 半边有 D11 的冻结控制面支路覆盖醒来的旧 Spring 的过期 claim,并有 store 测试钉住 bind 守卫;两个存活控制面仍属后续阶段。
|
||||
|
||||
## 10. 实现范围
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ bin/qwen-managed-agent-operator-recovery
|
||||
bin/qwen-managed-agent-admin
|
||||
bin/qwen-managed-agent-bootstrap
|
||||
bin/qwen-managed-agent-bootstrap.mjs
|
||||
bin/qwen-managed-agent-upgrade
|
||||
bin/qwen-managed-agent-upgrade.mjs
|
||||
config/application.example.yml
|
||||
systemd/qwen-hosted-harness.service
|
||||
systemd/qwen-managed-agent-server.service
|
||||
@@ -52,7 +54,7 @@ SHA256SUMS
|
||||
LICENSE
|
||||
```
|
||||
|
||||
The launchers use bundle-relative JAR paths. When the operator creates `config/application.yml` at the bundle root, they export `SPRING_CONFIG_ADDITIONAL_LOCATION` for it unless the variable is already set, so positional arguments reach Java unchanged. The template carries no active settings, so copying it changes nothing until it is edited. The server JAR includes Broker classes and resources; no separate Broker artifact or process is introduced. The `systemd/` templates encode the documented kill modes: `process` for the control plane so Runtime workers survive a Broker restart, and `mixed` for the Harness so its tool processes terminate with it.
|
||||
The launchers use bundle-relative JAR paths. When the operator creates `config/application.yml` at the bundle root, they export `SPRING_CONFIG_ADDITIONAL_LOCATION` for it unless the variable is already set, so positional arguments reach Java unchanged. The template carries no active settings, so copying it changes nothing until it is edited. The server JAR includes Broker classes and resources; no separate Broker artifact or process is introduced. The `systemd/` templates encode the documented kill modes: `process` for the control plane so Runtime workers survive a Broker restart, and `mixed` for the Harness so its tool processes terminate with it. The control-plane unit also counts the JVM's exit status 143 after `SIGTERM` as success.
|
||||
|
||||
The release also publishes a detached copy of `manifest.json` and `SHA256SUMS.txt`. The detached checksum file covers the archive and detached manifest. The in-bundle checksum file covers every payload file and `manifest.json`.
|
||||
|
||||
@@ -86,7 +88,7 @@ The distribution version is independent of the Maven component versions. The man
|
||||
|
||||
The operator verifies `SHA256SUMS.txt`, extracts the archive, reads `manifest.json`, installs the exact compatible Qwen CLI/Harness, configures MySQL and the existing server environment, starts `qwen serve --profile hosted-harness`, then starts the bundled server launcher. `/actuator/health` is the startup gate.
|
||||
|
||||
Upgrade uses one stopped admission window on the accepted single-control-plane topology: retain the database, Runtime credential key, and durable Runtime state; back them up; stop ingress, server, and Harness; verify the new artifact and compatibility manifest; start the matching Harness; then start the new server and allow Flyway to migrate forward. Traffic resumes only after health and a new no-tool Turn succeed. Database downgrade is not supported; rollback after a migration requires restoring the pre-upgrade database and state backup before accepting new writes.
|
||||
Upgrade uses one stopped admission window on the accepted single-control-plane topology: retain the database, Runtime credential key, and durable Runtime state; back them up; stop ingress, server, and Harness; verify the new artifact and compatibility manifest; start the matching Harness; then start the new server and allow Flyway to migrate forward. Traffic resumes only after health and a new no-tool Turn succeed. Database downgrade is not supported; rollback after a migration requires restoring the pre-upgrade database and state backup before accepting new writes. On the systemd layout, `bin/qwen-managed-agent-upgrade` runs the stop, backup, install, start and verification in phases, and prints the steps it leaves to the operator, such as stopping ingress and installing the Harness, where they fall; see the [upgrade command design](2026-10-25-managed-agent-upgrade-command.md).
|
||||
|
||||
## Failure semantics
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ bin/qwen-managed-agent-operator-recovery
|
||||
bin/qwen-managed-agent-admin
|
||||
bin/qwen-managed-agent-bootstrap
|
||||
bin/qwen-managed-agent-bootstrap.mjs
|
||||
bin/qwen-managed-agent-upgrade
|
||||
bin/qwen-managed-agent-upgrade.mjs
|
||||
config/application.example.yml
|
||||
systemd/qwen-hosted-harness.service
|
||||
systemd/qwen-managed-agent-server.service
|
||||
@@ -52,7 +54,7 @@ SHA256SUMS
|
||||
LICENSE
|
||||
```
|
||||
|
||||
启动脚本只使用 bundle 内的相对 JAR 路径。运维人员在 bundle 根目录创建 `config/application.yml` 后,启动脚本会在该变量未设置时导出 `SPRING_CONFIG_ADDITIONAL_LOCATION`,位置参数原样传给 Java。模板本身不包含生效配置,复制后在编辑前不改变任何行为。Server JAR 包含 Broker 类和资源,不另行提供 Broker 产物或进程。`systemd/` 模板落实了文档要求的 kill mode:控制面使用 `process`,使 Runtime worker 在 Broker 重启后继续存活;Harness 使用 `mixed`,使其工具进程随 Harness 终止。
|
||||
启动脚本只使用 bundle 内的相对 JAR 路径。运维人员在 bundle 根目录创建 `config/application.yml` 后,启动脚本会在该变量未设置时导出 `SPRING_CONFIG_ADDITIONAL_LOCATION`,位置参数原样传给 Java。模板本身不包含生效配置,复制后在编辑前不改变任何行为。Server JAR 包含 Broker 类和资源,不另行提供 Broker 产物或进程。`systemd/` 模板落实了文档要求的 kill mode:控制面使用 `process`,使 Runtime worker 在 Broker 重启后继续存活;Harness 使用 `mixed`,使其工具进程随 Harness 终止。控制面 unit 还把 JVM 收到 `SIGTERM` 后的退出状态 143 视为成功。
|
||||
|
||||
发布页同时提供独立的 `manifest.json` 副本与 `SHA256SUMS.txt`。外层校验和覆盖压缩包和独立 manifest;bundle 内的校验和覆盖全部载荷文件以及 `manifest.json`。
|
||||
|
||||
@@ -86,7 +88,7 @@ LICENSE
|
||||
|
||||
运维人员先校验 `SHA256SUMS.txt`,解压产物并读取 `manifest.json`,安装精确兼容的 Qwen CLI/Harness,配置 MySQL 和既有 server 环境,启动 `qwen serve --profile hosted-harness`,再运行 bundle 中的 server 启动脚本。`/actuator/health` 是启动门禁。
|
||||
|
||||
在已接受的单控制面拓扑中,升级使用一次停止准入窗口:保留数据库、Runtime credential key 和 durable Runtime state 并完成备份;停止入口流量、server 和 Harness;核验新产物及其兼容性 manifest;启动匹配的 Harness;再启动新 server,由 Flyway 向前迁移。只有健康检查和一个新的无工具 Turn 成功后才恢复流量。数据库不支持降级;发生 migration 后如需回滚,必须在接收新写入前恢复升级前的数据库与状态备份。
|
||||
在已接受的单控制面拓扑中,升级使用一次停止准入窗口:保留数据库、Runtime credential key 和 durable Runtime state 并完成备份;停止入口流量、server 和 Harness;核验新产物及其兼容性 manifest;启动匹配的 Harness;再启动新 server,由 Flyway 向前迁移。只有健康检查和一个新的无工具 Turn 成功后才恢复流量。数据库不支持降级;发生 migration 后如需回滚,必须在接收新写入前恢复升级前的数据库与状态备份。在 systemd 布局下,`bin/qwen-managed-agent-upgrade` 分阶段执行停止、备份、安装、启动和验证,并把留给运维的步骤(例如停止入口流量、安装 Harness)打印在对应位置,参见[升级命令设计](2026-10-25-managed-agent-upgrade-command.zh-CN.md)。
|
||||
|
||||
## 失败语义
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-15-managed-agent-server-layout.md) | [简体中文](2026-10-15-managed-agent-server-layout.zh-CN.md)
|
||||
|
||||
Successor: [Maven module boundaries](2026-10-29-managed-agent-maven-modules.md). This document retains its historical decisions and validation record; the successor defines the current build layout.
|
||||
|
||||
Status: implemented and validated locally. Remote CI and real MySQL/Linux deployment acceptance have not been run for this change.
|
||||
|
||||
## Problem and current state
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-15-managed-agent-server-layout.md) | [简体中文](2026-10-15-managed-agent-server-layout.zh-CN.md)
|
||||
|
||||
后继设计:[Maven 多模块边界](2026-10-29-managed-agent-maven-modules.zh-CN.md)。本文保留当时的决策与验证记录;当前构建结构以多模块设计为准。
|
||||
|
||||
状态:已实现并完成本地验证。本次尚未运行远端 CI 或真实 MySQL/Linux 部署验收。
|
||||
|
||||
## 问题与现状
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Release Jobs Off the Hosted Pool
|
||||
|
||||
> Superseded for activation by [release runner slots](2026-10-28-release-queue-optimization.md): reserve runner processes on existing shared hosts; an exclusive machine is not required.
|
||||
|
||||
[English](2026-10-16-release-jobs-off-hosted-pool.md) | [简体中文](2026-10-16-release-jobs-off-hosted-pool.zh-CN.md)
|
||||
|
||||
## Problem and Scope
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Release 任务迁出 GitHub 托管池
|
||||
|
||||
> 启用方案以[release runner 槽位](2026-10-28-release-queue-optimization.zh-CN.md)为准:在现有共享机器上预留 runner 进程,不要求独占整机。
|
||||
|
||||
[English](2026-10-16-release-jobs-off-hosted-pool.md) | [简体中文](2026-10-16-release-jobs-off-hosted-pool.zh-CN.md)
|
||||
|
||||
## 问题与范围
|
||||
|
||||
@@ -71,12 +71,19 @@ Following the established precedent (`hosted-harness-process.ts`,
|
||||
- `integration-tests/helpers/hosted-shell-quote.ts` — `quotePosix(value)`.
|
||||
Four call sites swap their local const for the import.
|
||||
|
||||
### Java: one package-private test-support class
|
||||
### Java: one shared test-support class
|
||||
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport` (final class,
|
||||
static methods, test scope), joining the existing test-support precedent
|
||||
(`CoordinatorTestSupport`):
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport` (public final
|
||||
class, static methods, test scope), joining the existing test-support
|
||||
precedent (`CoordinatorTestSupport`). It was package-private until the
|
||||
connector-owner resolver below had to be shared with a gate in the `harness`
|
||||
subpackage; only that resolver is public, the rest stay package-private:
|
||||
|
||||
- `connectorOwning(HarnessConnector)` — walks the delegate chain to the
|
||||
connector that owns the private state the gates read, because the wired
|
||||
bean is a decorator rather than that connector. `attachmentsOf` and the
|
||||
pool gate's concrete-type lookup both go through it, so there is one walk
|
||||
rather than a copy per read site.
|
||||
- `authentication(String tenant, String fixtureName)` — the
|
||||
`FilterRegistrationBean` factory; both probes delegate, the fixture name is
|
||||
the only parameter.
|
||||
|
||||
@@ -43,10 +43,11 @@
|
||||
- `integration-tests/helpers/hosted-sse-observer.ts` —— `Observer` 类(SSE fetch、块解析、`frames`、`through`、`terminalOfOtherTurn`、`turnIdOf`、`close`),`surface`、`sessionId`、`storeUrl`、`tenantId` 与超时全部经构造器注入;外加 `projectEvents(rows, surface)`,逐字承载 FG6e/FG8 的字段映射。FG6e 的 driver 不再为 observer 读模块级 config;两个 driver 都改为 import 该类。
|
||||
- `integration-tests/helpers/hosted-shell-quote.ts` —— `quotePosix(value)`。四个调用点把本地 const 换成 import。
|
||||
|
||||
### Java:一个包级私有的测试支撑类
|
||||
### Java:一个共享的测试支撑类
|
||||
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport`(final class、静态方法、test 作用域),加入既有测试支撑先例(`CoordinatorTestSupport`)之列:
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport`(public final class、静态方法、test 作用域),加入既有测试支撑先例(`CoordinatorTestSupport`)之列。它原本是包级私有,直到下面这个 connector owner 解析器需要与 `harness` 子包里的一条门禁共享;只有该解析器是 public,其余仍为包级私有:
|
||||
|
||||
- `connectorOwning(HarnessConnector)` —— 沿 delegate 链走到持有「门禁要读的私有状态」的那个 connector,因为 wiring 交出的 bean 是装饰器而不是该 connector。`attachmentsOf` 与 pool 门禁的具体类型查找都走它,因此只有一份走链,而不是每个读取点一份拷贝。
|
||||
- `authentication(String tenant, String fixtureName)` —— `FilterRegistrationBean` 工厂;两个 probe 都改为委托,fixture 名是唯一参数。
|
||||
- relay 循环的共享机制:用钉死的 fixture token 与 capability digest 构建 client、boot-id 能力校验、`parseSession` 反射桥,以及「流 → 投影 → 落库」的行走,源键为 `bootId:epoch:eventId`。各 letter 的循环控制留在一个小策略缝后面,恰好三个钩子——事件属于哪个 turn(FG6e 的固定 `promptId` 对 FG8 的每事件 prompt id)、一个允许阻塞的落库前钩子(FG6e 的 terminal 闩)、以及 terminal 是否停下的决定(FG6e 遇 terminal 跳出;FG8 从不,靠流关闭停下)。FG8 关闭流引发的 `IllegalStateException` 吞留处理留在 FG8 的 probe 里。
|
||||
- `toolPublicationArgs(int servicePort, String bucket, long executionBytes)` —— 那 16 行属性。
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-19-managed-agent-package-boundaries.md) | [简体中文](2026-10-19-managed-agent-package-boundaries.zh-CN.md)
|
||||
|
||||
Successor: [Maven module boundaries](2026-10-29-managed-agent-maven-modules.md). This document retains its historical decisions and validation record; the successor defines the current build layout.
|
||||
|
||||
Status: implemented and verified locally on main base `f37e003d9450`. Remote CI and formal review are tracked on [PR #14710](https://github.com/QwenLM/qwen-code/pull/14710). The optional MySQL, Hosted and Linux fault profiles were not run locally for this package reorganization.
|
||||
|
||||
## Problem and current state
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-19-managed-agent-package-boundaries.md) | [简体中文](2026-10-19-managed-agent-package-boundaries.zh-CN.md)
|
||||
|
||||
后继设计:[Maven 多模块边界](2026-10-29-managed-agent-maven-modules.zh-CN.md)。本文保留当时的决策与验证记录;当前构建结构以多模块设计为准。
|
||||
|
||||
状态:已在主干基点 `f37e003d9450` 上实现并完成本地验证。远端 CI 与正式评审状态见 [PR #14710](https://github.com/QwenLM/qwen-code/pull/14710)。本次包调整未在本地运行可选的 MySQL、Hosted 和 Linux 故障专项 profile。
|
||||
|
||||
## 问题与现状
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-20-sdk-java-fair-lock-queue.md) | [简体中文](2026-10-20-sdk-java-fair-lock-queue.zh-CN.md)
|
||||
|
||||
Database integration now runs on MySQL only. References below to the former MariaDB integration site retain historical measurements; its unchanged lock, cache write-back and budget rules apply to the retained MySQL job. See the [current database verification scope](managed-runtime-broker-jdbc.md#database-verification-scope).
|
||||
|
||||
## Problem
|
||||
|
||||
`.github/workflows/sdk-java.yml` serializes every self-hosted Maven step on one
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-20-sdk-java-fair-lock-queue.md) | [简体中文](2026-10-20-sdk-java-fair-lock-queue.zh-CN.md)
|
||||
|
||||
数据库集成现仅运行 MySQL。下文原 MariaDB 集成站点的相关内容保留历史测量记录;其未变的锁、缓存写回和预算规则适用于保留的 MySQL 任务。参见[当前数据库验证范围](managed-runtime-broker-jdbc.zh-CN.md#数据库验证范围)。
|
||||
|
||||
## 问题
|
||||
|
||||
`.github/workflows/sdk-java.yml` 通过 `flock --wait` 把所有 self-hosted 的
|
||||
|
||||
@@ -61,8 +61,8 @@ Goals:
|
||||
- Never touch the user's working tree, and never force-push or push to the
|
||||
default branch.
|
||||
- Reply to the feedback it handled, so reviewers can see what changed.
|
||||
- Stop on its own when the PR is merged or closed, or when the attempt budgets
|
||||
run out, and report why.
|
||||
- Stop on its own when the PR is merged or closed, or when a budget that ends
|
||||
the watch runs out (Section 10), and report why.
|
||||
- Wake the session only when something changed; a quiet check costs no model
|
||||
turn.
|
||||
|
||||
@@ -72,6 +72,9 @@ Non-goals (first version):
|
||||
- PRs in repositories without a matching local remote.
|
||||
- Resolving review threads. Reviewers keep that decision.
|
||||
- Opening new PRs or rewriting PR titles and descriptions.
|
||||
- Approving, merging or closing the PR, changing its review state, or turning
|
||||
on auto-merge. Getting the PR ready to merge is the whole job; the
|
||||
repository's own requirements and a person decide the rest.
|
||||
|
||||
## 4. User experience
|
||||
|
||||
@@ -96,6 +99,9 @@ Before it starts, the command shows one confirmation:
|
||||
- It will push commits to `<owner>/<repo>:<headRef>` and post replies on the
|
||||
PR, both under the user's own git and `gh` identity.
|
||||
- Verification runs repository-defined build and test commands.
|
||||
- It gets the PR ready to merge and no further: it never approves, changes
|
||||
the review state, merges or turns on auto-merge, so the repository's
|
||||
reviews and checks still decide and merging stays the user's.
|
||||
|
||||
The confirmation is asked on every start, not on scheduled checks. A session
|
||||
that cannot show the confirmation needs `--yes`.
|
||||
@@ -143,9 +149,11 @@ Helpers live under `packages/cli/src/commands/autofix-pr.ts` and
|
||||
`packages/cli/src/commands/autofix-pr/`, and the skill runs them through the
|
||||
`autofix_pr` core tool (Section 6.12); the reviewer probe lives with the
|
||||
other `qwen review` helpers. Per-PR state lives next to the worktree, outside
|
||||
it, under the clone's main worktree. Branches are shared by every worktree of
|
||||
a clone, so each helper finds the same watch from any of them, the autofix
|
||||
worktree included:
|
||||
it, under the worktree the watch was prepared from — the session's own
|
||||
worktree in a daemon session, else the clone's main worktree (see
|
||||
[2026-10-29-autofix-pr-worktree-rooted-storage.md](2026-10-29-autofix-pr-worktree-rooted-storage.md)).
|
||||
Branches are shared by every worktree of a clone; the watch listing scans them
|
||||
all, so a watch is found whichever of them holds it:
|
||||
|
||||
- worktree: `.qwen/tmp/autofix-pr-<n>/`
|
||||
- state: `.qwen/tmp/autofix-pr-<n>-state.json`
|
||||
@@ -155,7 +163,9 @@ worktree included:
|
||||
- what GitHub said of bot comments: `.qwen/tmp/autofix-pr-<n>-replies.json`
|
||||
- logs, briefs and reply plans: `.qwen/tmp/autofix-pr-<n>-*.{log,md,json}`
|
||||
- the user's instructions for every round: `.qwen/tmp/autofix-pr-<n>-instructions.txt`
|
||||
- reviewer cursor: `.qwen/tmp/review-watch/<host>/<owner>/<repo>.json`
|
||||
- reviewer cursor: `.qwen/tmp/review-watch/<host>/<owner>/<repo>.json` — this
|
||||
one stays under the clone's **main** worktree (`repoRoot()`), not the watch
|
||||
root: `qwen review watch-repo` is a clone-level probe, not a per-session watch
|
||||
|
||||
## 6. Helper contracts
|
||||
|
||||
@@ -205,7 +215,8 @@ worktree included:
|
||||
worktree was removed (also with `rm -rf`, which `git worktree prune`
|
||||
clears), it is recreated and the recorded `pushedShas`,
|
||||
`handledCommentIds` and `postedCommentIds` are kept, with the budgets,
|
||||
the recorded task (`watch`) and `needsHuman`, and the report says
|
||||
the recorded task (`watch`), `needsHuman` and what a delivered summary
|
||||
reported (`reportedNeedsHuman`), and the report says
|
||||
`recreated: true`: a start that is then cancelled must not clean up a
|
||||
watch it did not make. A watch `stop` had ended is resumed the same way
|
||||
(`wasEnded`, as on the path that keeps the worktree). With `--take-over`, the
|
||||
@@ -214,11 +225,15 @@ worktree included:
|
||||
another repository's PR of the same number is a refusal. Repositories
|
||||
compare as on GitHub: owner and name without case, and the host without
|
||||
case or the default port, but with any other port, which may name another
|
||||
instance. In a daemon session (`QWEN_CODE_SERVE=1`), a worktree outside
|
||||
the session's working directory is a refusal before the state file is
|
||||
read, on a resume or take-over as on a new watch: the daemon runs a fix
|
||||
agent's commands only inside that directory, so a round there could not
|
||||
test, commit or push. The refusal names the clone root to prepare from.
|
||||
instance. In a daemon session (`QWEN_CODE_SERVE=1`), the watch is rooted
|
||||
at the session's own worktree toplevel, so a session in a linked worktree
|
||||
of the clone prepares it inside its own directory. A worktree that would
|
||||
still fall outside the session's working directory (the session sits in a
|
||||
subdirectory of the worktree, or in another clone) is a refusal before the
|
||||
state file is read, on a resume or take-over as on a new watch: the daemon
|
||||
runs a fix agent's commands only inside that directory, so a round there
|
||||
could not test, commit or push. The refusal names the directory to prepare
|
||||
from.
|
||||
8. Print a JSON report: worktree path, branch, head SHA, base/head refs, push
|
||||
target, viewer and PR author logins, draft and fork flags, and
|
||||
`localUnpushedCommits`, the number of commits on the user's own copy of the
|
||||
@@ -362,6 +377,59 @@ Event rules:
|
||||
and the run's id, once per head
|
||||
(`<workflow>@<head>@#startup:<workflow id>:<event>`), with no log and
|
||||
nothing to re-run, so the skill answers it as needing a human.
|
||||
- The budget of fix pushes per failing check (Section 10) is the one record
|
||||
a prepared snapshot keeps, in `failingChecks` of the state file: one entry
|
||||
per check (by its identity, as above) that a round saw fail and has not
|
||||
seen pass since, with the head it last failed on, the number of the
|
||||
watch's later pushes it failed on, and the push the count runs to
|
||||
(`lastPush`). It counts pushes, not fixes: a push for a review comment
|
||||
that the check fails on counts too. A pass (`SUCCESS` or `NEUTRAL`; a
|
||||
status's `SUCCESS`) ends the entry. Someone else's push gives every entry
|
||||
its pushes back, counted again from the watch's latest push: `sync` and
|
||||
`prepare` do so when they adopt one, whether or not a round reads it,
|
||||
and so does a read of such a head. A return to an earlier push of the
|
||||
watch spends nothing. Only a read of the head the watch knows (the one
|
||||
`sync` or `push` recorded) counts: any other is the API lagging behind
|
||||
the branch, or a push the round syncs to and reads again. A read whose
|
||||
head rollup is cut short (at the page limit, the time limit, or by the
|
||||
head moving while it pages) changes nothing either: a later run of a
|
||||
check may be on the pages not read. The gate wakes no round for a pass,
|
||||
so a round may never read one:
|
||||
the watch can push again (for a comment) before its fix's checks end, or
|
||||
a re-run can pass after the round moved on. So when a check fails on a
|
||||
later push of the watch, the snapshot asks GitHub what it ended in on the
|
||||
watch's pushes in between that the entry did not count, and — when a
|
||||
round re-ran it there (not one only held for a run still going, which
|
||||
starts on its own head only) — on the head it last failed on
|
||||
(`commitCheckResults`: one GraphQL read of a commit's rollup, paged like
|
||||
the PR's, a rollup cut at the page limit taken for no answer; at most 4
|
||||
commits a read, shared by every check and each asked once: every entry's
|
||||
re-run first, then its pushes in between newest first, before any head
|
||||
asked about again). A pass there ends the entry as a pass a round reads would, so
|
||||
this failure is a first one again; a failure on a push in between spends
|
||||
one (a re-run that failed again on the last head spends nothing more). A
|
||||
head with no result spends nothing. One that may still get one — still
|
||||
running, a re-run GitHub has not listed yet (no check run after the one
|
||||
it re-ran), not listed while other checks of the commit still run (a job
|
||||
that needs another, a status posted once CI ends), or no answer from
|
||||
GitHub or past a read's 4 commits — stays
|
||||
on the entry (`unanswered`, at most 10, as many as a watch's pushes) and
|
||||
is asked about again on its next push: a pass found there then leaves
|
||||
only the pushes counted since a round next saw the check fail, and a
|
||||
failure found there spends nothing — only a result known when a push is
|
||||
counted spends. One that ended with none (cancelled, skipped, or not
|
||||
listed once the commit's checks have all ended) is dropped, and so is a head the round then sees fail on a return
|
||||
to it. Only a read that changes an entry takes the watch's lock, to
|
||||
re-read the state and write it; the entries a read changed go last, and
|
||||
at most 100 are kept. Once 3 pushes left a check failing, its `ci_failed`
|
||||
carries `exhausted: true`, and the skill answers it as needing a human
|
||||
instead of fixing it again — once per head, like any failure handled
|
||||
without a push — until a pass ends the entry or someone else's push gives
|
||||
it back. A pass someone saw only on a head the watch counted (a re-run
|
||||
started by hand there) is not asked about. The entry stays with the
|
||||
watch through a take-over, a resume and a recreated worktree; a new start
|
||||
begins without it, as the push budget does, and so does every
|
||||
`--in-place` start (one round each).
|
||||
- A comment of a review not submitted yet (`PENDING`) is a draft only its
|
||||
author sees, and is skipped. A bot asks in the comment that opens a
|
||||
thread (and in a review); its replies answer someone and are not
|
||||
@@ -415,7 +483,9 @@ Event rules:
|
||||
|
||||
Action precedence: `stop` (merged or closed) → `resync` (head moved
|
||||
externally) → `fix` (a conflict, a failed check or a trusted comment) →
|
||||
`wait`. The loop adds three more inputs: an exhausted budget means `stop`; a
|
||||
`wait`. The loop adds three more inputs: an exhausted budget that ends the
|
||||
watch (Section 10) means `stop` — a check out of fix pushes is answered as
|
||||
needing a human and leaves the action as it was; a
|
||||
background fix in flight (its attempt marker, Section 6.14) keeps the gate
|
||||
quiet and the prepared snapshot refused until the attempt ends; and an
|
||||
attempt its session left behind (a stale marker) is a source of `fix` too —
|
||||
@@ -665,10 +735,16 @@ The helper decides what may be posted, against one fresh read of the PR
|
||||
- Each POST is announced in the state file first (`pendingPosts`: where it
|
||||
goes, the comments it answers, a hash of its body, the head it is posted
|
||||
on — a summary adopted later counts for that head's push, not a later
|
||||
one's). The next run looks for
|
||||
one's — and, for a summary, the `needsHuman` lines its body reports). The
|
||||
next run looks for
|
||||
each announced reply on the PR: one that is there — a POST that reached
|
||||
GitHub although its answer was lost — is recorded with exactly the
|
||||
comments it was posted for (`adopted`); one that is not was never posted.
|
||||
comments it was posted for (`adopted`), and a summary with exactly the
|
||||
items it reported, merged with what an earlier delivered summary already
|
||||
reported on that head: adopting a later summary never makes an earlier
|
||||
item reportable again, and a summary that reported no items erases none.
|
||||
A record of another head is superseded, not merged. One that is not on the
|
||||
PR was never posted.
|
||||
A reply is never recognized by a later plan's wording, and a reply the
|
||||
tool posted for other comments is never taken for the answer to new ones.
|
||||
- A comment a reply names that is answered already is left out of it; the
|
||||
@@ -681,7 +757,19 @@ The helper decides what may be posted, against one fresh read of the PR
|
||||
posted (above) and skips a reply whose comments are all handled, and the
|
||||
summary when its comments are — unless the head is a push of the watch's
|
||||
that has no summary yet: each push gets one, once per head
|
||||
(`summarizedSha`), whether or not it answers comments.
|
||||
(`summarizedSha`), whether or not it answers comments. A summary is due
|
||||
as well whenever the plan names a `needsHuman` line that no delivered
|
||||
summary has reported on this head, so a round that pushed nothing still
|
||||
reports what blocks it on a head an earlier round already summarized.
|
||||
What a delivered summary reported is its own key (`reportedNeedsHuman`:
|
||||
the head and the lines), advanced only once the summary posted — or was
|
||||
adopted, from the items its own intent recorded. It is deliberately not
|
||||
the `needsHuman` list, which is written before any POST so `status`
|
||||
survives a later failure: a trigger read from that list is spent before
|
||||
delivery, and a summary whose POST never reached GitHub would then be
|
||||
suppressed on every retry while the round still recorded its failures
|
||||
handled. An undelivered report stays due; a delivered one is not posted
|
||||
twice.
|
||||
- `handledChecks` and `handledConflict` record failures and a conflict the
|
||||
round did not fix with a push, so prepared snapshots stop reporting them:
|
||||
a failure by its attempt (a re-run that fails again is new), a conflict by
|
||||
@@ -690,7 +778,9 @@ The helper decides what may be posted, against one fresh read of the PR
|
||||
keeps each in the state file with the head it was answered on and the
|
||||
time, newest last and at most 20, for `status`. Whitespace is folded, a
|
||||
line over 300 characters is cut, and the same line on the same head is
|
||||
kept once, so running a plan again adds nothing.
|
||||
kept once, so running a plan again adds nothing. This list is the record
|
||||
`status` reads, written before any POST; it is not what decides whether a
|
||||
summary is due (above).
|
||||
- `--no-post` (the skill's `--no-reply`) records the same, the summary's
|
||||
`summarizedSha` included, and posts nothing.
|
||||
|
||||
@@ -922,7 +1012,9 @@ treated as real once the run has ended. That is a known limit.
|
||||
two helper calls the model issues at once would otherwise each write back
|
||||
what they read, undoing the other's record of a push, a reply or a re-run.
|
||||
`prepare` holds it while it creates the worktree and the state file, so two
|
||||
starts at once cannot roll back or take over each other's worktree.
|
||||
starts at once cannot roll back or take over each other's worktree, and
|
||||
`snapshot` holds it while it writes the fix budget of a check, and only
|
||||
when a read changes it (Section 6.2).
|
||||
The lock is created whole, as a hard link to a file already holding its
|
||||
token, and removed only while it still holds that token. A sandbox may run
|
||||
each command in a pid namespace of its own, where pids repeat, so the
|
||||
@@ -937,10 +1029,12 @@ into, and a fresh lock is never touched.
|
||||
|
||||
### 6.10 `status [<pr>]`
|
||||
|
||||
Lists the watches of the clone, read from their files under the main
|
||||
worktree's `.qwen/tmp` (`--no-work` leaves `worktreeWork` null, skipping
|
||||
its git scan): no lock is taken and GitHub is not asked, so it
|
||||
answers while a helper runs and whichever session owns a watch. Output:
|
||||
Lists the watches of the clone, read from their files under each worktree's
|
||||
`.qwen/tmp` — every worktree of the clone is scanned, so a watch rooted in any
|
||||
of them is listed (`--no-work` leaves `worktreeWork` null, skipping its git
|
||||
scan): no lock is taken and GitHub is not asked, so it answers while a helper
|
||||
runs and whichever session owns a watch. An explicit root scans just that one
|
||||
worktree. Output:
|
||||
`{ "now": "<time>", "watches": [...] }`, and `unreadable` with the PR and
|
||||
the reason for a state file that could not be read. Each watch carries:
|
||||
|
||||
@@ -1415,9 +1509,8 @@ comes, or comes too late, leaves the attempt to the `abandoned_fix` round.
|
||||
`reply` without `--no-post` refuses on a watch started with `--no-reply`, so
|
||||
that option holds even when a turn forgets it.
|
||||
|
||||
**Not in this step.** The budget of fix attempts per failing check without
|
||||
turning green (Section 10) needs a record of which checks passed, which no
|
||||
helper keeps yet; it stays a proposal.
|
||||
The budget of fix pushes per failing check (Section 10) came after this
|
||||
step: prepared snapshots record which checks failed and passed (Section 6.2).
|
||||
|
||||
### 6.15 The Web Shell: watches and their tasks
|
||||
|
||||
@@ -1428,13 +1521,14 @@ watch of the workspaces it reaches.
|
||||
**What the daemon serves.** `GET /autofix-pr/watches` (the primary
|
||||
workspace) and `GET /workspaces/:workspace/autofix-pr/watches` (a registered,
|
||||
trusted one), advertised as `autofix_pr_watches`, answer what `status`
|
||||
lists for the workspace's clone. The main worktree is found with
|
||||
`git worktree list`, so a workspace that is a linked worktree or a
|
||||
subdirectory reads the clone's watches, and the answer names it (`root`).
|
||||
`runStatus` takes that root and no session, rather than the daemon's own
|
||||
directory and environment, and runs no git command (`git: false`): the
|
||||
daemon lists the watches on its own thread, where a git command that hangs
|
||||
would hold every request. It reads the files only, each opened without
|
||||
lists for the workspace's clone. One `git worktree list` finds every
|
||||
worktree of the clone, so a workspace that is a linked worktree or a
|
||||
subdirectory reads the clone's watches; the answer still names the main
|
||||
worktree as `root` (for display). `runStatus` takes that set of worktrees
|
||||
(`roots`) and no session, rather than the daemon's own directory and
|
||||
environment, and runs no git command (`git: false`): the daemon lists the
|
||||
watches on its own thread, where a git command that hangs would hold every
|
||||
request. It reads the files only, each opened without
|
||||
following a link and without blocking, so a FIFO put in place of one cannot
|
||||
hold the daemon either (the helpers read them so too). No lock is taken and
|
||||
GitHub is not asked, so the answer comes back while a helper runs. Left out
|
||||
@@ -1553,7 +1647,9 @@ disagreement, contradictory comments, a request outside the PR's scope) is
|
||||
replied to as "needs a human decision" and recorded in `needsHuman`. The loop
|
||||
keeps watching other events. The round answers such items on the PR, reports
|
||||
them in its message and names them in the reply plan's `needsHuman`, which
|
||||
`status` lists (Sections 6.5, 6.10).
|
||||
`status` lists (Sections 6.5, 6.10). A round that pushed nothing posts them
|
||||
as its summary too, so the PR shows that the watch stopped instead of going
|
||||
quiet (Section 9).
|
||||
|
||||
## 8. Trust model
|
||||
|
||||
@@ -1619,6 +1715,11 @@ Replies are on by default; `--no-reply` turns them off.
|
||||
or `Not changed: <reason>`, or `Needs a human decision: <question>`.
|
||||
- Each push gets one PR comment summarizing what it addressed: checks fixed or
|
||||
re-run, comments addressed, conflict resolved, and how it was verified.
|
||||
- A round that pushed nothing and left something for a person gets one too:
|
||||
what it attempted, what blocks it and what a person has to decide. It goes
|
||||
out once per head, and again only when the round names an item no delivered
|
||||
summary has reported on that head (Section 6.5). One whose POST never
|
||||
reached the PR is still owed, so the next run of the plan posts it.
|
||||
- Replies use the language of the comment they answer; the summary uses the
|
||||
language of the PR's title, which `snapshot` reports (`title`).
|
||||
- Every body ends with `<!-- qwen-autofix-pr:v1 -->`.
|
||||
@@ -1676,14 +1777,14 @@ Replies are on by default; `--no-reply` turns them off.
|
||||
|
||||
Budgets, stored in the state file:
|
||||
|
||||
| Budget | Default | When exhausted |
|
||||
| ---------------------------------------------------- | ------- | ------------------------------ |
|
||||
| Fix attempts per failing check without turning green | 3 | stop the loop |
|
||||
| Re-runs per check per head SHA | 1 | treat the next failure as real |
|
||||
| Fix attempts per review comment | 1 | never offered again |
|
||||
| Pushes per start | 10 | stop the loop |
|
||||
| Consecutive external head moves | 2 | stop the loop |
|
||||
| Minutes one background fix may run | 30 | stop its agent, discard it |
|
||||
| Budget | Default | When exhausted |
|
||||
| -------------------------------------------------- | ------- | -------------------------------------------------------- |
|
||||
| Fix pushes per failing check without turning green | 3 | left for a person until it passes or someone else pushes |
|
||||
| Re-runs per check per head SHA | 1 | treat the next failure as real |
|
||||
| Fix attempts per review comment | 1 | never offered again |
|
||||
| Pushes per start | 10 | stop the loop |
|
||||
| Consecutive external head moves | 2 | stop the loop |
|
||||
| Minutes one background fix may run | 30 | stop its agent, discard it |
|
||||
|
||||
Stop conditions: the PR is merged or closed; `/autofix-pr stop`; a budget in
|
||||
the table says stop; the cron job expires. Every stop but the scheduled task's expiry (7 days by default: `experimental.cronRecurringMaxAgeDays`, or `QWEN_CODE_CRON_MAX_AGE_DAYS`), which ends the watch without a round, runs `cleanup` and
|
||||
@@ -1731,10 +1832,17 @@ Validation:
|
||||
rollup paging; the reviewer probe's events, filters and cursor pruning.
|
||||
- Push guard tests against a local bare repository: non-fast-forward refusal,
|
||||
default-branch refusal, lease rejection after an external push.
|
||||
- An integration test with a fake `gh` on `PATH` that drives `prepare` →
|
||||
`snapshot` → `push` → `reply` → `cleanup` — not written yet; each helper
|
||||
is tested on its own against real git with a bare remote and a stubbed
|
||||
`gh`.
|
||||
- An integration test with a fake `gh` on `PATH`
|
||||
(`autofix-pr/chain.integration.test.ts`) that runs the helpers in the
|
||||
order a watch runs them — `prepare` and `schedule`; a round's `sync`,
|
||||
`snapshot`, `ci-log`, `rerun`, `push` and `reply`; the gate exactly as
|
||||
`schedule` printed it; `status` and `cleanup` — against real git with a
|
||||
bare remote, the test making the fix agent's commits. Its cases: a watch
|
||||
from start to merge, including a reply whose answer was lost; a push by
|
||||
someone else during a round; a re-run spent, then refused; a check left
|
||||
for a person after three pushes, one head's result asked of GitHub by
|
||||
commit; and whether the PR's author can push, asked of GitHub. Each helper
|
||||
is also tested on its own, with `gh` stubbed in-process.
|
||||
- Manual end-to-end on a sandbox repository: a failing test is fixed and
|
||||
pushed; a collaborator comment is fixed and answered; a stranger's comment is
|
||||
ignored; merging the PR ends the loop and removes the worktree.
|
||||
@@ -1795,7 +1903,10 @@ Delivery plan:
|
||||
background (Section 6.14), stopped at the 30-minute limit of Section 10.
|
||||
The Web Shell shows each watch on its task's card and on a PR watches
|
||||
page, and stops one in the session that owns it (Section 6.15).
|
||||
Still to come: the fix-attempts-per-failing-check budget of Section 10.
|
||||
Delivered later (2026-10-31): the budget of fix pushes per failing check
|
||||
of Section 10. When it runs out, the check is answered as needing a human
|
||||
and the watch goes on, rather than stopping: the watch still answers
|
||||
review feedback and the other checks.
|
||||
5. Follow-ups: a configurable bot allowlist.
|
||||
|
||||
Open questions:
|
||||
|
||||
@@ -34,7 +34,7 @@ PR 提交之后,作者还得一直盯着:CI 在没测过的平台上失败
|
||||
- 在本地会话里盯住一个 PR,响应 CI 失败、可信作者的 review 反馈和合并冲突。
|
||||
- 绝不碰用户的工作区,绝不强推,绝不推到默认分支。
|
||||
- 回复它处理过的反馈,让 reviewer 看到改了什么。
|
||||
- PR 合并或关闭时、或尝试预算用完时自行停止,并说明原因。
|
||||
- PR 合并或关闭时、或会结束监视的预算用完时(第 10 节)自行停止,并说明原因。
|
||||
- 只在有变化时唤醒会话;安静的检查不花模型回合。
|
||||
|
||||
非目标(第一版):
|
||||
@@ -43,6 +43,7 @@ PR 提交之后,作者还得一直盯着:CI 在没测过的平台上失败
|
||||
- 本地没有匹配 remote 的仓库里的 PR。
|
||||
- resolve review thread。这个决定留给 reviewer。
|
||||
- 新开 PR,或改写 PR 标题和描述。
|
||||
- approve、合并或关闭 PR,改变它的评审状态,或开启 auto-merge。把 PR 修到可合就是它的全部职责;其余由仓库自身的要求和人决定。
|
||||
|
||||
## 4. 用户体验
|
||||
|
||||
@@ -63,6 +64,7 @@ PR 提交之后,作者还得一直盯着:CI 在没测过的平台上失败
|
||||
|
||||
- 它会向 `<owner>/<repo>:<headRef>` 推送提交,并在 PR 上发回复,两者都使用用户自己的 git 和 `gh` 身份。
|
||||
- 验证会运行仓库定义的构建和测试命令。
|
||||
- 它只把 PR 修到可合,仅此而已:从不 approve、改变评审状态、合并或开启 auto-merge,所以仍由仓库的评审和检查决定,合并仍是用户的事。
|
||||
|
||||
每次启动都会确认,定时检查不会。无法显示确认的会话需要加 `--yes`。
|
||||
|
||||
@@ -95,7 +97,7 @@ qwen review watch-repo the reviewer's probe: PRs opened, pushed or marke
|
||||
ready since the last run, behind a gate of its own
|
||||
```
|
||||
|
||||
辅助命令放在 `packages/cli/src/commands/autofix-pr.ts` 和 `packages/cli/src/commands/autofix-pr/` 下,skill 通过 core 工具 `autofix_pr` 运行它们(第 6.12 节);评审者探针和其他 `qwen review` 辅助命令放在一起。每个 PR 的状态放在 worktree 旁边,不放在 worktree 里面,位于 clone 的主 worktree 下。分支由同一个 clone 的所有 worktree 共享,所以每个辅助命令从其中任何一个 worktree(包括 autofix worktree 本身)运行,都能找到同一次监视:
|
||||
辅助命令放在 `packages/cli/src/commands/autofix-pr.ts` 和 `packages/cli/src/commands/autofix-pr/` 下,skill 通过 core 工具 `autofix_pr` 运行它们(第 6.12 节);评审者探针和其他 `qwen review` 辅助命令放在一起。每个 PR 的状态放在 worktree 旁边,不放在 worktree 里面,位于这次监视 prepare 时所在的 worktree 下——daemon 会话里是会话自身的 worktree,否则是 clone 的主 worktree(见 [2026-10-29-autofix-pr-worktree-rooted-storage.zh-CN.md](2026-10-29-autofix-pr-worktree-rooted-storage.zh-CN.md))。分支由同一个 clone 的所有 worktree 共享;监视列表会扫描全部 worktree,所以无论监视落在哪一个里都能找到:
|
||||
|
||||
- worktree:`.qwen/tmp/autofix-pr-<n>/`
|
||||
- 状态:`.qwen/tmp/autofix-pr-<n>-state.json`
|
||||
@@ -105,7 +107,9 @@ qwen review watch-repo the reviewer's probe: PRs opened, pushed or marke
|
||||
- GitHub 对 bot 评论的答复:`.qwen/tmp/autofix-pr-<n>-replies.json`
|
||||
- 日志、修复说明和回复计划:`.qwen/tmp/autofix-pr-<n>-*.{log,md,json}`
|
||||
- 用户给每一轮的附加指令:`.qwen/tmp/autofix-pr-<n>-instructions.txt`
|
||||
- 评审者游标:`.qwen/tmp/review-watch/<host>/<owner>/<repo>.json`
|
||||
- 评审者游标:`.qwen/tmp/review-watch/<host>/<owner>/<repo>.json`——这一个仍在
|
||||
clone 的**主** worktree(`repoRoot()`)下,而非 watch 根:`qwen review
|
||||
watch-repo` 是 clone 级探针,不是按会话的 watch
|
||||
|
||||
## 6. 辅助命令契约
|
||||
|
||||
@@ -117,7 +121,7 @@ qwen review watch-repo the reviewer's probe: PRs opened, pushed or marke
|
||||
4. 确定推送目标。优先使用唯一一个和 head 仓库匹配、并且只推送到它的本地 remote:匹配器只读 fetch URL,所以该 remote 推送到的每一个 URL——它的 `pushurl`,或者多条 `url` 里的每一条——都用同一个匹配器再核对一遍,任何一个目的地在别处的 remote 都不算。没有的话,按 `gh config get git_protocol` 给出的协议使用 head 仓库的 URL。把结果记到状态文件里。
|
||||
5. 状态文件就是锁。它记录会话 id(`QWEN_CODE_SESSION_ID`);由另一个会话写下的状态文件直接导致拒绝。`/clear` 和 `/resume` 会给会话换一个新 id,而它的定时任务继续运行,所以在用户同意时,`prepare --take-over` 把监视(连同历史和预算)移交给发起请求的会话;被移交的那个会话的辅助命令从此拒绝执行,它的定时任务在下一次触发时停止:它的闸门写明创建它的会话(`--session`,即 `prepare` 报告的 `sessionId`),在那之前不推进游标——写游标之前会再确认一次监视归谁——只把它唤醒(`watched_elsewhere`,`session` 是拥有者)。每个任务还写明 `schedule` 为它生成的 id(`--task`,第 6.11 节):id 与状态文件记录的不一致的闸门——被之后的启动或 resume 替换掉的任务,或者同一会话在第二个进程(`qwen --continue`)里的副本,这时辅助命令会接受它的轮次——同样这样回答;监视已被 `cleanup` 删除的闸门回答 `watch_ended`。两种事件都写明闸门自己的会话和任务(`gateSession`、`gateTask`),这一轮据此精确删除那个任务。原拥有者已在进行中的一轮,会被它下一次辅助命令调用拦下,因为那些调用也带着任务(第 6.8 节);但它的修复 agent 不调用辅助命令,拦不住,可能继续往 worktree 里提交,新拥有者的一轮可能把这些提交推上去。这是已知限制:`status` 报告 `checkedRecently` 时,接管和 resume 的提问会说明这一点,最好等另一个会话停下后再接管。
|
||||
6. 把 `refs/pull/<n>/head` fetch 到本地分支 `qwen-autofix/pr-<n>`,并在它上面创建 worktree。`prepare` 从不丢弃之前的工作:残留的 worktree,或可能含有超出记录 head 的提交的残留分支,都会导致拒绝,并提示先运行 `cleanup`。
|
||||
7. 在拥有这次监视的会话里重新运行 `prepare`,且 worktree 还在时,直接报告现有状态并带上 `resumed: true`,只向 GitHub 询问 PR 作者是谁、能否推送,并按本 clone 的 remote 重新推导 remote 和推送目标:状态文件就在 worktree 旁边,一轮运行的代码够得着它,所以这些都不从它读回;本 clone 不服务的仓库直接拒绝。只有权限 API 返回 404 才表示作者不能推送;其他失败(限流、网络)都是什么也不改的拒绝,接管也只在拿到这些答案之后才写入。head 仓库已被删除的 PR 同样拒绝。新建监视时,作者能否推送在获取或创建任何东西之前就询问,所以被拒时不留下任何东西。监视的每个文件——状态、游标、history、回复缓存、快照——都只在是普通文件时才读取,所以以其名字放置的 FIFO 卡不住辅助命令或闸门;写入时替换链接而不是跟随它,所以没有辅助命令会写到 `.qwen/tmp` 之外。如果 worktree 已被删除(包括用 `rm -rf` 删除,`git worktree prune` 会清掉它的登记),就重新创建它,并保留已记录的 `pushedShas`、`handledCommentIds` 和 `postedCommentIds`,以及预算、记下的任务(`watch`)和 `needsHuman`,报告里写 `recreated: true`:随后被取消的启动不能清理一个不是它创建的监视。`stop` 已结束的监视也以同样方式恢复(`wasEnded`,与保留 worktree 的路径相同)。带 `--take-over` 时,只有 PR 通过第 2 步的检查后才把监视移交给本会话,被拒时监视仍归原来的会话。状态文件属于另一个仓库的同号 PR 时直接拒绝。仓库按 GitHub 的方式比较:owner 和仓库名不区分大小写;host 不区分大小写、忽略默认端口,但保留其他端口,因为不同端口可能是不同的实例。在 daemon 会话里(`QWEN_CODE_SERVE=1`),worktree 不在会话工作目录之内时,在读取状态文件之前就拒绝,恢复、接管和新建监视都一样:daemon 只在那个目录里运行修复 agent 的命令,在那里的一轮无法测试、提交或推送。拒绝信息会写明应该从哪个 clone 根目录运行 prepare。
|
||||
7. 在拥有这次监视的会话里重新运行 `prepare`,且 worktree 还在时,直接报告现有状态并带上 `resumed: true`,只向 GitHub 询问 PR 作者是谁、能否推送,并按本 clone 的 remote 重新推导 remote 和推送目标:状态文件就在 worktree 旁边,一轮运行的代码够得着它,所以这些都不从它读回;本 clone 不服务的仓库直接拒绝。只有权限 API 返回 404 才表示作者不能推送;其他失败(限流、网络)都是什么也不改的拒绝,接管也只在拿到这些答案之后才写入。head 仓库已被删除的 PR 同样拒绝。新建监视时,作者能否推送在获取或创建任何东西之前就询问,所以被拒时不留下任何东西。监视的每个文件——状态、游标、history、回复缓存、快照——都只在是普通文件时才读取,所以以其名字放置的 FIFO 卡不住辅助命令或闸门;写入时替换链接而不是跟随它,所以没有辅助命令会写到 `.qwen/tmp` 之外。如果 worktree 已被删除(包括用 `rm -rf` 删除,`git worktree prune` 会清掉它的登记),就重新创建它,并保留已记录的 `pushedShas`、`handledCommentIds` 和 `postedCommentIds`,以及预算、记下的任务(`watch`)、`needsHuman` 和已送达总结报告过的事项(`reportedNeedsHuman`),报告里写 `recreated: true`:随后被取消的启动不能清理一个不是它创建的监视。`stop` 已结束的监视也以同样方式恢复(`wasEnded`,与保留 worktree 的路径相同)。带 `--take-over` 时,只有 PR 通过第 2 步的检查后才把监视移交给本会话,被拒时监视仍归原来的会话。状态文件属于另一个仓库的同号 PR 时直接拒绝。仓库按 GitHub 的方式比较:owner 和仓库名不区分大小写;host 不区分大小写、忽略默认端口,但保留其他端口,因为不同端口可能是不同的实例。在 daemon 会话里(`QWEN_CODE_SERVE=1`),监视以会话自身 worktree 的 toplevel 为根,所以开在 clone 的 linked worktree 里的会话会把监视准备在自己的目录之内。若 worktree 仍会落在会话工作目录之外(会话位于该 worktree 的子目录,或在另一个 clone 里),则在读取状态文件之前就拒绝,恢复、接管和新建监视都一样:daemon 只在那个目录里运行修复 agent 的命令,在那里的一轮无法测试、提交或推送。拒绝信息会写明应该从哪个目录运行 prepare。
|
||||
8. 输出 JSON 报告:worktree 路径、分支、head SHA、base/head ref、推送目标、当前用户和 PR 作者的 login、draft 和 fork 标志,以及 `localUnpushedCommits`,即用户自己那份 head 分支上 PR 还没有的提交数。worktree 看不到这些提交。只有 upstream 是推送 remote 上 head 分支的本地分支才算这份副本,因此 fork 的 `main` 永远不会和用户自己的 `main` 比较。报告还带 `localUncommittedChanges`:这个 clone 里是否有 worktree 检出了同一份副本、并且有 git 报告的未提交改动。读取时带 `--no-optional-locks`、`--untracked-files=normal` 和 `--ignore-submodules=none`,所以用户的 `status.showUntrackedFiles` 或 submodule 的 `ignore` 设置藏不住这些改动;只排除辅助命令写入的 `.qwen/tmp`,`.qwen` 下的其他内容都算用户的。只在这份副本存在时计算,且只算检出该分支的 worktree,检出别的分支的 worktree 里的改动不属于这份副本。`worktree list --porcelain -z` 可避免路径被引号包裹;git 没有 `-z` 时(2.36 之前)回退到行格式,被它加引号的路径会读成未知而不是「没有工作」。`localWorkUnknown` 表示根本无法比较——PR head 不在这个 clone 里,或 git 读不了某个检出——这种情况永远不会被报成「没有工作」。两个字段都只是报告,不是拒绝:工作为真或该未知标记为真时,skill 的启动不再继续(除非用户知情后要求继续),因为监视的推送与用户自己的推送去的是同一个 head 分支。
|
||||
|
||||
### 6.2 `snapshot <pr> [--repo <owner>/<repo>] [--host <host>] [--gate] [--session <id>] [--task <id>]`
|
||||
@@ -187,6 +191,7 @@ qwen review watch-repo the reviewer's probe: PRs opened, pushed or marke
|
||||
- head SHA 和 `lastKnownHeadSha` 不同时,如果分支本身(对推送目标执行 `git ls-remote`)也已离开已知 head——包括回退到本监视早先推送过的 head——记为 `head_moved_externally`;分支仍在已知 head 上时,是 API 滞后(上文的 `lagging`)。读不到分支时,`pushedShas` 里的 head 视为本监视自己的。
|
||||
- `title` 是 PR 的标题,这一轮的总结使用它的语言。
|
||||
- GitHub 没能启动的 workflow(`STARTUP_FAILURE`,常见原因是 workflow 文件无效)在 rollup 里没有任何 check run——它是唯一的 workflow 时根本没有 rollup——只留下它的 check suite。在读到的 suite 里(如上所述向前翻页),每个 workflow 和触发事件只看最近一次运行,而且只看由 PR 代码触发的(`pull_request`、`pull_request_target`、`push`):为每条 review 评论触发的运行在 GitHub 出问题时也会启动失败,与代码无关。它被报告为带 `startupFailure: true` 和 run id 的 `ci_failed`,每个 head 只报一次(`<workflow>@<head>@#startup:<workflow id>:<event>`),没有日志也没有可重跑的 job,所以 skill 把它答复为需要人工处理。
|
||||
- 每个失败检查的修复推送预算(第 10 节)是 prepared 快照唯一保存的记录,存在状态文件的 `failingChecks` 里:每个被某一轮看到失败、此后还没看到通过的检查(按上文的检查身份)一条,记着它最后一次失败所在的 head、它在监视之后的几次推送上失败,以及计数截至的那次推送(`lastPush`)。它数的是推送而不是修复:为评审评论做的推送、检查在上面失败的,也计入。通过(`SUCCESS` 或 `NEUTRAL`;status 的 `SUCCESS`)结束这一条。别人的推送让每一条都拿回推送次数,从监视最近的推送重新计起:`sync` 和 `prepare` 采纳别人的推送时就这样做,不论之后有没有哪一轮读到它;读取这样的 head 也一样。回到监视更早的推送不消耗。只有读到监视已知的 head(`sync` 或 `push` 记下的那个)才计数:其他的要么是 API 落后于分支,要么是这一轮会同步过去再读一次的推送。head 的 rollup 被截断的读取(在页数上限处、到了时限,或翻页期间 head 移动)也不改变任何一条:某个检查更晚的一次运行可能在没读到的页上。闸门不会因为通过而唤醒任何一轮,所以通过可能没有哪一轮读到:监视可能在修复的检查结束前又推送了一次(为评审评论),重跑也可能在这一轮继续往下走之后才通过。因此检查在监视之后的推送上失败时,快照会向 GitHub 询问它在中间这些尚未计入的监视推送上的结果,以及——某一轮在它最后失败的 head 上重跑过时(只为仍在进行的 run 延后的重跑不算,它只在自己的 head 上发起)——在那个 head 上的结果(`commitCheckResults`:读一次该提交的 rollup,与 PR 的 rollup 一样翻页,在页数上限处被截断的 rollup 按没有回应处理;每次读取最多问 4 个提交,由所有检查共用,每个只问一次:先问各条的重跑,再从最新的问起问它的中间推送,最后才问需要再问的 head)。那里通过,就像某一轮读到通过一样结束这一条,这次失败重新算作第一次;中间某次推送上失败则消耗一次(在最后失败的 head 上重跑后又失败,不再多消耗)。没有结果的 head 不消耗。之后还可能有结果的——仍在运行、GitHub 还没列出的重跑(被重跑的那次 check run 之后还没有新的)、该提交其他检查仍在运行时尚未列出的(依赖别的 job 的 job、CI 结束后才发的 status),或 GitHub 没有回应、超出一次读取的 4 个提交——留在这一条上(`unanswered`,最多 10 个,与一个监视的推送数相同),在它的下一次推送时再问:那时查到通过,则只保留某一轮此后再次看到检查失败以来计入的次数;查到失败则不消耗——只有计入某次推送时已知的结果才消耗。已经结束却没有结果的(被取消、被跳过,或该提交的检查全部结束后仍未列出)被丢弃;回到某个 head 时这一轮看到它失败,那个 head 也被丢弃。只有改变了某一条的读取才会加监视的锁,重新读取状态后写入;被读取改变的条目排在最后,最多保留 100 条。3 次推送都没让检查变绿后,它的 `ci_failed` 带上 `exhausted: true`,skill 把它作为需要人工处理来回答,而不再修复——像其他不经推送处理的失败一样,每个 head 一次——直到一次通过结束这一条,或别人的推送把次数还给它。只在监视已计入的 head 上被人看到的通过(有人在那里手动重跑)不会被查询。接管、resume 和重建 worktree 都保留这条记录;新的启动不带它,与推送预算一样;每次 `--in-place` 启动(每次一轮)也不带。
|
||||
- 尚未提交的 review 里的评论(`PENDING`)是只有作者能看到的草稿,会被跳过。bot 在开启 thread 的评论(以及 review)里提要求;它在 thread 里的回复是在回答某人,不算请求。被回复的评论删除后,GraphQL 会丢掉回复的 `replyTo`,并把它列为 thread 的第一条,所以看起来像是开启了 thread、又尚未见过的 bot 评论,会通过 REST 逐条查询(`in_reply_to_id` 仍保留在这条评论上),只针对打开的 PR 上未解决的 thread,而且每条只问一次:答案保存在 `.qwen/tmp/autofix-pr-<n>-replies.json`,闸门、已准备快照和 `reply` 共用。期间被删除的评论不算请求。每条答案一拿到就保存。闸门每次触发最多问 20 条、不超出闸门的读取时限,遇到第一次失败就停,剩下的留到下一次触发,这次既不算请求也不记为已看过;一轮的快照则问完所有条目,GitHub 没有回应时直接失败,绝不丢掉闸门已经报告过的请求(`reply` 按闸门的方式询问:它的计划只会点名快照问过的 thread);问了八分钟仍未问完时就停下,保留已有答案,并提示再运行一次。闸门也把 bot 的回复记为已看过。否则,会回复每条回复的评审 bot 会让每次都多跑一轮,不受信任的评论者也能借 bot 之口提要求,删掉自己的评论之后也一样。
|
||||
- 失败的检查只有在它是 PR 所在仓库的 GitHub Actions job 时才带 `runId`(它的 suite 有 workflow run,URL 位于 `https://<host>/<owner>/<repo>/actions/runs/` 之下):commit status 或其他应用的检查可以链接任意 run,`ci-log` 会去读它。
|
||||
- 已合并或已关闭的 PR 只读第一次调用:它的结果就是停止,不需要再翻检查、suite 或 thread 的后续页。
|
||||
@@ -195,7 +200,7 @@ qwen review watch-repo the reviewer's probe: PRs opened, pushed or marke
|
||||
- 已准备模式把整个快照缩进后写到 `.qwen/tmp/autofix-pr-<n>-snapshot.json`,只输出一行:去掉事件的快照、事件数量和类型,以及文件路径(`snapshot`)。有一点历史的 PR 上,评论正文远远超出一行工具输出能显示的长度;这一轮读取该文件,修复说明列出修复 agent 要处理的条目,正文从该文件读取。正文写成按行的数组,超过 2,000 字符的行写成由若干片段组成的数组(绝不在一个字符中间切开),因为 `read_file` 按行分页,一次读取返回的内容超过 25,000 字符就截断;被这样截断的读取要从它最后显示的那一行重新读。
|
||||
- `truncated` 列出条目数超过查询读取范围的连接(`checks`、`reviewThreadComments`、`reviewThreads`、`reviews`、`comments`)。
|
||||
|
||||
动作优先级:`stop`(已合并或已关闭)→ `resync`(head 被别人移动)→ `fix`(冲突、失败的检查或可信评论)→ `wait`。循环会再加三个输入:预算用完时为 `stop`;后台修复进行中(它的尝试标记,第 6.14 节)时,闸门保持安静,prepared 快照被拒绝,直到这次尝试结束;被它的会话遗留下来的尝试(过期的标记)也是 `fix` 的来源——监视自己的闸门把它作为一次 `abandoned_fix` 事件报告,所以即使没有别的变化,也会有一轮丢弃这次尝试的工作并重做修复。
|
||||
动作优先级:`stop`(已合并或已关闭)→ `resync`(head 被别人移动)→ `fix`(冲突、失败的检查或可信评论)→ `wait`。循环会再加三个输入:会结束监视的预算(第 10 节)用完时为 `stop`——修复推送用完的检查作为需要人工处理来回答,动作保持不变;后台修复进行中(它的尝试标记,第 6.14 节)时,闸门保持安静,prepared 快照被拒绝,直到这次尝试结束;被它的会话遗留下来的尝试(过期的标记)也是 `fix` 的来源——监视自己的闸门把它作为一次 `abandoned_fix` 事件报告,所以即使没有别的变化,也会有一轮丢弃这次尝试的工作并重做修复。
|
||||
|
||||
#### 闸门模式(`--gate`)
|
||||
|
||||
@@ -258,12 +263,12 @@ qwen review watch-repo the reviewer's probe: PRs opened, pushed or marke
|
||||
- 其他 head 的失败键(这一轮已推送越过了它)被忽略并计入 `ignoredChecks`:prepared snapshot 只读当前 head。根本不是失败键的字符串会被拒绝。`handledConflict` 只在 GitHub 报告冲突时,或者在本监视推送的 head 上报告 `UNKNOWN` 时,才记在当前 head 上:推送刚完成时 GitHub 会报告 `UNKNOWN`,直到重新算完合并。
|
||||
- 同一 thread 有两条回复,或同一个评论 id 出现两次的计划会被拒绝。
|
||||
- 所有检查在发出任何内容之前完成,被拒绝的计划什么都不发。
|
||||
- 每次 POST 之前先在状态文件里登记(`pendingPosts`:发往哪里、回复哪些评论、正文的哈希、发帖时的 head——之后认领的总结只算作那个 head 的推送,而不是更晚的推送)。下一次运行会在 PR 上找每条登记过的回复:找到的——POST 已到达 GitHub 但响应丢失——按它当初回复的那些评论记录(`adopted`);找不到的说明从未发出。回复永远不会按之后计划的措辞来认,为别的评论发的回复也永远不会被当成对新评论的回答。
|
||||
- 每次 POST 之前先在状态文件里登记(`pendingPosts`:发往哪里、回复哪些评论、正文的哈希、发帖时的 head——之后认领的总结只算作那个 head 的推送,而不是更晚的推送——总结还带上它正文所报告的 `needsHuman` 行)。下一次运行会在 PR 上找每条登记过的回复:找到的——POST 已到达 GitHub 但响应丢失——按它当初回复的那些评论记录(`adopted`),总结则按它当初报告的那些事项记录,并与该 head 上更早的已送达总结报告过的事项**取并集**:认领一条更晚的总结绝不会让更早的事项重新变得可报告,而一条没有报告任何事项的总结也不会抹掉已有记录。另一个 head 的记录是被取代,不是被合并。找不到的说明从未发出。回复永远不会按之后计划的措辞来认,为别的评论发的回复也永远不会被当成对新评论的回答。
|
||||
- 回复里列出的评论如果已经回答过,就从这条回复里去掉;回复仍会回答其余评论,所以一轮运行期间新来的评论不会没人回答。
|
||||
- 每条正文只加一次标记,且必须在 GitHub 的 65,536 字符以内。
|
||||
- 每个发出的 id 一产生就写入状态文件,连同它所回复的评论。中途失败时,其余部分不发也不记录;用同一份计划重跑时会认出已发出的内容(见上),回复的评论全部已处理才跳过,评论都已处理的总结也跳过——除非该 head 是本监视推送且尚无总结:每次推送都有一条总结,每个 head 一条(记在 `summarizedSha`),不论它是否回答评论。
|
||||
- 每个发出的 id 一产生就写入状态文件,连同它所回复的评论。中途失败时,其余部分不发也不记录;用同一份计划重跑时会认出已发出的内容(见上),回复的评论全部已处理才跳过,评论都已处理的总结也跳过——除非该 head 是本监视推送且尚无总结:每次推送都有一条总结,每个 head 一条(记在 `summarizedSha`),不论它是否回答评论。此外,只要计划点名的 `needsHuman` 行是该 head 上任何**已送达**总结都没报告过的,总结也照发:这样即使上一轮已在这个 head 发过总结,什么都没推送的一轮仍会在 PR 上说明它被什么卡住。已送达总结报告过什么是独立的键(`reportedNeedsHuman`:head 与那些行),只在这条总结真的发出之后才前进——或者被认领时,按它自己意图里记下的事项前进。它刻意不用 `needsHuman` 列表:那份列表在任何 POST 之前就写入,为的是更晚失败之后 `status` 仍看得到;从它读取的触发器会在送达之前就被消耗掉,于是一条 POST 从未到达 GitHub 的总结,会在每次重跑时被压制,而这一轮却照样把失败记为已处理。未送达的报告仍然欠着;已送达的不会发第二次。
|
||||
- `handledChecks` 和 `handledConflict` 记录这一轮没有靠推送修好的失败和冲突,prepared snapshot 不再报告它们:失败按 attempt 记(重跑后再次失败算新的),冲突按 head 记。
|
||||
- `needsHuman` 每行一项,是这一轮留给人处理的事项。辅助命令把每项连同回答它时的 head 和时间记入状态文件,按时间先后排列,最多保留最新的 20 项,供 `status` 列出。空白会折叠,超过 300 字符的行会被截断,同一 head 上的同一行只记一次,所以重跑同一份计划不会增加条目。
|
||||
- `needsHuman` 每行一项,是这一轮留给人处理的事项。辅助命令把每项连同回答它时的 head 和时间记入状态文件,按时间先后排列,最多保留最新的 20 项,供 `status` 列出。空白会折叠,超过 300 字符的行会被截断,同一 head 上的同一行只记一次,所以重跑同一份计划不会增加条目。这份列表是 `status` 读的记录,在任何 POST 之前写入;它不决定总结是否该发(见上)。
|
||||
- `--no-post`(skill 的 `--no-reply`)记录同样的内容(包括总结的 `summarizedSha`),但不发任何东西。
|
||||
|
||||
回复 review thread 使用 `POST /repos/{owner}/{repo}/pulls/{n}/comments/{id}/replies`;总结是一条 issue 评论。两种 POST 都不重试:在传输错误之前已到达 GitHub 的请求会发出两次。
|
||||
@@ -306,11 +311,11 @@ qwen review watch-repo the reviewer's probe: PRs opened, pushed or marke
|
||||
|
||||
GitHub 只在 run 结束后才能重跑它,而 `snapshot` 在 job 一失败就报告。因此仍在进行的 run 不询问 GitHub,直接回答 `{"rerun": false, "deferred": true}`,并记入 `deferredRuns`:该 run 结束时闸门会再报告一次它的失败(第 6.2 节),届时那一轮再重跑。在那之前 prepared snapshot 也不列出它们,这样别的事件不会把同一个失败再交给这一轮延后一次。延后为它的每个失败检查保留那一次重跑(记在 `heldChecks` 里,与已花掉的 `rerunChecks` 分开,带上该 run 的 id 和被延后的 check run,所以别的 run 花掉的重跑永远不会被当成这个 run 保留的),也只隐藏这些 check run:该 run 里延后之后才失败的 job 立即交给这一轮,不论 run 还要跑多久。该 run 结束时重跑它——这次重跑(或回答丢失)会把该 run 的所有保留都算作已花掉,包括在 run 里已不再失败的检查(单独重跑过的 job 已经用过它的重跑);而在该 head 上这个检查更晚的一次运行——比如它结束前某次 review 启动的运行——视同重跑的回答,以重跑的键报告(第 6.2 节),并按真实失败处理。同一 workflow 里同名的两个 job 算同一个检查:第二个在延后之后才失败时,那次重跑已由第一个占用,run 结束后第二个按真实失败处理。这是已知限制。
|
||||
|
||||
`push`、`sync`、`reply` 和 `rerun` 在读写状态文件期间都持有这次监视的锁(`.qwen/tmp/autofix-pr-<n>.lock`):否则模型同时发出的两个辅助命令调用会各自写回自己读到的内容,抹掉对方关于推送、回复或重跑的记录。`prepare` 在创建 worktree 和状态文件期间也持有它,所以同时进行的两次启动不会回滚或接管对方的 worktree。锁以硬链接的方式整体创建(链接到一个已写好令牌的文件),并且只在仍持有自己令牌时才删除。沙箱可能让每条命令运行在独立的 pid 命名空间里,pid 会重复,所以只在同一命名空间里才检查持有者的 pid;其他锁在存在时间超过任何辅助命令的运行时长(十五分钟)后被接管,`cleanup --force` 则会立即删除它。过期锁一次只由一个辅助命令打破,在独占的 `<lock>.break` 文件保护下进行(被杀掉的辅助命令留下的 `.break` 在一分钟后清除,不论时钟偏前还是偏后);此时锁里仍是过期令牌,就用一次 rename 把它替换成打破者自己的令牌,所以锁路径从不出现空档让别的辅助命令趁机拿到,新的锁也永远不会被动到。
|
||||
`push`、`sync`、`reply` 和 `rerun` 在读写状态文件期间都持有这次监视的锁(`.qwen/tmp/autofix-pr-<n>.lock`):否则模型同时发出的两个辅助命令调用会各自写回自己读到的内容,抹掉对方关于推送、回复或重跑的记录。`prepare` 在创建 worktree 和状态文件期间也持有它,所以同时进行的两次启动不会回滚或接管对方的 worktree;`snapshot` 在写入某个检查的修复预算时也持有它,且只在读取改变了预算时才拿(第 6.2 节)。锁以硬链接的方式整体创建(链接到一个已写好令牌的文件),并且只在仍持有自己令牌时才删除。沙箱可能让每条命令运行在独立的 pid 命名空间里,pid 会重复,所以只在同一命名空间里才检查持有者的 pid;其他锁在存在时间超过任何辅助命令的运行时长(十五分钟)后被接管,`cleanup --force` 则会立即删除它。过期锁一次只由一个辅助命令打破,在独占的 `<lock>.break` 文件保护下进行(被杀掉的辅助命令留下的 `.break` 在一分钟后清除,不论时钟偏前还是偏后);此时锁里仍是过期令牌,就用一次 rename 把它替换成打破者自己的令牌,所以锁路径从不出现空档让别的辅助命令趁机拿到,新的锁也永远不会被动到。
|
||||
|
||||
### 6.10 `status [<pr>]`
|
||||
|
||||
列出这个 clone 的监视,从主 worktree 的 `.qwen/tmp` 下它们的文件读取(`--no-work` 让 `worktreeWork` 为 null,跳过它的 git 扫描):不取锁,也不询问 GitHub,所以辅助命令运行期间也能回答,且不论监视归哪个会话。输出为 `{ "now": "<时间>", "watches": [...] }`;读不了的状态文件放在 `unreadable` 里,带 PR 和原因。每个监视包含:
|
||||
列出这个 clone 的监视,从每个 worktree 的 `.qwen/tmp` 下它们的文件读取——扫描 clone 的全部 worktree,所以落在任何一个里的监视都会被列出(`--no-work` 让 `worktreeWork` 为 null,跳过它的 git 扫描):不取锁,也不询问 GitHub,所以辅助命令运行期间也能回答,且不论监视归哪个会话。显式指定 root 时只扫描那一个 worktree。输出为 `{ "now": "<时间>", "watches": [...] }`;读不了的状态文件放在 `unreadable` 里,带 PR 和原因。每个监视包含:
|
||||
|
||||
- PR、它的仓库、host 和 URL;`headRefName`、`pushTarget` 和监视已知的 head(`headSha`);
|
||||
- `pushes` 和 `maxPushes`;
|
||||
@@ -402,13 +407,13 @@ agent 超过尝试时限(第 10 节,从尝试打开起 30 分钟)时,会
|
||||
|
||||
不带 `--no-post` 的 `reply` 在以 `--no-reply` 启动的监视上会拒绝,所以即使某个回合忘了这个选项,它也照样生效。
|
||||
|
||||
**不在这一步里。** 每个失败检查在变绿之前的修复尝试预算(第 10 节)需要记录哪些检查通过了,目前没有辅助命令记录这一点;它仍然只是提议。
|
||||
每个失败检查的修复推送预算(第 10 节)在这一步之后才交付:prepared 快照记录哪些检查失败、哪些通过(第 6.2 节)。
|
||||
|
||||
### 6.15 Web Shell:监视及其任务
|
||||
|
||||
Web Shell 在监视的主人本来就会看的地方显示它——它的定时任务卡片——并另有一页 **PR 监视**,列出它能访问的各工作区的全部监视。
|
||||
|
||||
**daemon 提供什么。** `GET /autofix-pr/watches`(主工作区)和 `GET /workspaces/:workspace/autofix-pr/watches`(已注册且受信任的工作区),以 `autofix_pr_watches` 声明,返回 `status` 为该工作区的 clone 列出的内容。主 worktree 用 `git worktree list` 找到,所以工作区是 linked worktree 或子目录时,读到的也是这个 clone 的监视,答复里会写明它(`root`)。`runStatus` 接收这个根目录、不带会话,而不是用 daemon 自己的目录和环境,并且不运行任何 git 命令(`git: false`):daemon 在自己的线程上列出监视,一条卡住的 git 命令会拖住所有请求。它只读文件,每个文件都以不跟随链接、不阻塞的方式打开,所以有人把某个文件换成 FIFO 也拖不住 daemon(辅助命令也这样读)。不取锁、不询问 GitHub,所以辅助命令运行期间也能回答。不返回的有:`thisSession`(daemon 不是会话)、worktree 的路径,以及只有 git 才能给出的 `gitTrusted`、`worktreeWork`。找不到该工作区的 git 仓库、或那里无法运行 git 时,`available` 为 false。这些路由不修改任何东西。
|
||||
**daemon 提供什么。** `GET /autofix-pr/watches`(主工作区)和 `GET /workspaces/:workspace/autofix-pr/watches`(已注册且受信任的工作区),以 `autofix_pr_watches` 声明,返回 `status` 为该工作区的 clone 列出的内容。用一次 `git worktree list` 找到 clone 的全部 worktree,所以工作区是 linked worktree 或子目录时,读到的也是这个 clone 的监视;答复里的 `root` 仍是主 worktree(用于展示)。`runStatus` 接收这组 worktree(`roots`)、不带会话,而不是用 daemon 自己的目录和环境,并且不运行任何 git 命令(`git: false`):daemon 在自己的线程上列出监视,一条卡住的 git 命令会拖住所有请求。它只读文件,每个文件都以不跟随链接、不阻塞的方式打开,所以有人把某个文件换成 FIFO 也拖不住 daemon(辅助命令也这样读)。不取锁、不询问 GitHub,所以辅助命令运行期间也能回答。不返回的有:`thisSession`(daemon 不是会话)、worktree 的路径,以及只有 git 才能给出的 `gitTrusted`、`worktreeWork`。找不到该工作区的 git 仓库、或那里无法运行 git 时,`available` 为 false。这些路由不修改任何东西。
|
||||
|
||||
**哪个监视属于哪个任务。** 定时任务没有类型字段:监视的任务按 `schedule` 写的提示词识别——`Run /autofix-pr tick <owner>/<repo>#<n> [--task <id>]…`——与任务所在工作区里同仓库、同编号的监视对应。任务是否过时,按它的闸门的判断:监视已结束、任务带 id 而监视没有记录任务,监视记录的是另一个任务(之后的 `schedule` 替换了它;早于任务 id 的任务也不算记录的那个),或监视已属于另一个会话(被接管了)。过时的任务会被它的闸门在下次触发时停止,卡片上也会说明。
|
||||
|
||||
@@ -469,7 +474,7 @@ snapshot --gate (in the gate)
|
||||
|
||||
resync 时如果新 head 不包含 worktree 的 `HEAD`(作者 rebase 或 force-push 了),worktree 会被重置到新 head,而不是快进。工具推送过的内容此时已在作者手中,是作者改写的那段历史的一部分。重置最多丢掉一次没有推送的尝试,它基于旧的 head,`push` 本来也会拒绝它;这次尝试按上面的规则结束。
|
||||
|
||||
一轮检查从不向用户提问。需要人来判断的情况(设计分歧、互相矛盾的评论、超出 PR 范围的要求)会回复"需要人工决定"并记入 `needsHuman`。循环继续关注其他事件。这一轮会在 PR 上回复这类事项,在本轮消息里报告,并写进回复计划的 `needsHuman`,由 `status` 列出(第 6.5、6.10 节)。
|
||||
一轮检查从不向用户提问。需要人来判断的情况(设计分歧、互相矛盾的评论、超出 PR 范围的要求)会回复"需要人工决定"并记入 `needsHuman`。循环继续关注其他事件。这一轮会在 PR 上回复这类事项,在本轮消息里报告,并写进回复计划的 `needsHuman`,由 `status` 列出(第 6.5、6.10 节)。什么都没推送的一轮也会把这些事项作为总结发在 PR 上,让 PR 显示监视停在这里,而不是默默无声(第 9 节)。
|
||||
|
||||
## 8. 信任模型
|
||||
|
||||
@@ -500,6 +505,7 @@ resync 时如果新 head 不包含 worktree 的 `HEAD`(作者 rebase 或 force
|
||||
|
||||
- 每个处理过的 review thread 收到一条回复:`Fixed in <short-sha>: <one line>`、`Not changed: <reason>` 或 `Needs a human decision: <question>`。
|
||||
- 每次推送发一条 PR 评论,总结这次处理了什么:修复或重跑的检查、处理的评论、解决的冲突,以及如何验证的。
|
||||
- 什么都没推送、但留下了需要人处理事项的一轮也发一条:它尝试了什么、被什么卡住、需要人决定什么。每个 head 一条,只有当这一轮点名了该 head 上任何已送达总结都没报告过的事项时才再发(第 6.5 节)。POST 从未到达 PR 的那一条仍然欠着,所以重跑同一份计划会把它发出去。
|
||||
- 回复使用被回复评论的语言;总结使用 PR 标题的语言(`snapshot` 报告的 `title`)。
|
||||
- 每条正文末尾都带 `<!-- qwen-autofix-pr:v1 -->`。
|
||||
- 工具从不 resolve review thread。
|
||||
@@ -513,14 +519,14 @@ resync 时如果新 head 不包含 worktree 的 `HEAD`(作者 rebase 或 force
|
||||
|
||||
预算记录在状态文件里:
|
||||
|
||||
| 预算 | 默认值 | 用完时 |
|
||||
| ------------------------------------ | ------ | ------------------------ |
|
||||
| 同一个失败检查在变绿之前的修复次数 | 3 | 停止循环 |
|
||||
| 每个检查在每个 head SHA 上的重跑次数 | 1 | 下一次失败按真实失败处理 |
|
||||
| 每条 review 评论的修复次数 | 1 | 不再提供 |
|
||||
| 每次启动的推送次数 | 10 | 停止循环 |
|
||||
| 连续被别人移动 head 的次数 | 2 | 停止循环 |
|
||||
| 一次后台修复可运行的分钟数 | 30 | 停止它的 agent,丢弃它 |
|
||||
| 预算 | 默认值 | 用完时 |
|
||||
| -------------------------------------- | ------ | -------------------------------- |
|
||||
| 同一个失败检查在变绿之前的修复推送次数 | 3 | 留给人处理,直到它通过或别人推送 |
|
||||
| 每个检查在每个 head SHA 上的重跑次数 | 1 | 下一次失败按真实失败处理 |
|
||||
| 每条 review 评论的修复次数 | 1 | 不再提供 |
|
||||
| 每次启动的推送次数 | 10 | 停止循环 |
|
||||
| 连续被别人移动 head 的次数 | 2 | 停止循环 |
|
||||
| 一次后台修复可运行的分钟数 | 30 | 停止它的 agent,丢弃它 |
|
||||
|
||||
停止条件:PR 已合并或关闭;执行 `/autofix-pr stop`;上表中某项预算要求停止;cron 任务过期。除了定时任务过期(默认 7 天:`experimental.cronRecurringMaxAgeDays` 或 `QWEN_CODE_CRON_MAX_AGE_DAYS`)会不经过任何一轮就结束监视之外,每次停止都会执行 `cleanup`,并输出原因、已做的推送和所有 `needsHuman` 项。
|
||||
|
||||
@@ -549,7 +555,7 @@ resync 时如果新 head 不包含 worktree 的 `HEAD`(作者 rebase 或 force
|
||||
|
||||
- 用录制的 `gh` JSON fixture 做单元测试:事件对比、旧 SHA 上的过时检查、各信任类别、回复标记和 id 的自我排除、动作优先级、预算;闸门游标规则(每个 head 一次、重跑后的失败、冲突标记、当前用户自己的评论、合并与重新打开)和 check 汇总翻页;评审者探针的事件、过滤项和游标清理。
|
||||
- 用本地 bare 仓库测试推送保护:拒绝非 fast-forward、拒绝默认分支、别人推送后 lease 被拒。
|
||||
- 在 `PATH` 上放一个假的 `gh` 做集成测试,跑通 `prepare` → `snapshot` → `push` → `reply` → `cleanup`——尚未编写;目前每个辅助命令各自用真实 git、裸远端仓库和打桩的 `gh` 测试。
|
||||
- 在 `PATH` 上放一个假的 `gh` 做集成测试(`autofix-pr/chain.integration.test.ts`):按监视运行它们的顺序依次运行各辅助命令——`prepare` 和 `schedule`;一轮里的 `sync`、`snapshot`、`ci-log`、`rerun`、`push` 和 `reply`;按 `schedule` 打印的原样执行的 gate;`status` 和 `cleanup`——用真实 git 和裸远端仓库,修复 agent 的提交由测试代为完成。用例:从启动到合入的一次监视,其中有一次回复的应答丢失;一轮进行中别人推送;重跑用掉一次后被拒;三次推送后仍失败的检查交给人,其中一个 head 的结果按提交向 GitHub 查询;向 GitHub 查询 PR 作者能否推送。每个辅助命令另有各自的测试,`gh` 在进程内打桩。
|
||||
- 在沙箱仓库上手工端到端验证:失败的测试被修复并推送;协作者的评论被修复并回复;陌生人的评论被忽略;合并 PR 后循环结束,worktree 被删除。
|
||||
|
||||
验收标准:
|
||||
@@ -566,7 +572,7 @@ resync 时如果新 head 不包含 worktree 的 `HEAD`(作者 rebase 或 force
|
||||
3. 辅助命令 `push` 和 `reply`,带测试;以及 skill:支持 `--once` 的 `/autofix-pr`,然后是作为带闸门任务的 watch 模式,以及对每个事件运行 `/qreview` 或一次验证的评审者监视。状态文件记录已处理过的失败检查 attempt,键与闸门游标相同(检查,head,attempt),这样 agent 已放弃的失败不会被再次派发,而重跑后再次失败的会。
|
||||
除评审者监视外已交付(2026-10-24):`push`、`sync`(第 6.8 节)、`reply`、`rerun`(第 6.9 节),以及支持 `--once`、watch 模式、`stop` 和简单 `status` 的 bundled skill。这一步里修复 agent 在前台运行:会话等它结束,所以不会有触发再启动第二次尝试(第 4 步的后台修复加上了第 6.14 节的尝试标记)。它执行的预算是每次启动的推送次数(`push`)、每个检查每个 head 一次重跑(`rerun`),以及每条 review 评论一次尝试(一轮处理过的评论无论结果如何都会被记录;最后一次推送所在的那一轮是监视的最后一轮);同一轮内两次 resync 会停止监视;一轮没能推送的工作以 `sync --discard` 结束,下一轮不会建立在它之上。第 8 节的沙箱规则在启动时执行,每一轮都先运行 `sync`,所以 GitHub API 尚未显示的推送或回退也会被跟上;推送之前还会再 sync 一次,把修复 agent 的合并留在原处的子模块移过去。skill 的 `allowedTools` 只放行读取它的 `.qwen/tmp` 文件、写它的修复说明、回复计划和用户的附加指令(最后一项由第 4 步加上),以及 `cron_list`——不放行任何 shell 命令。权限匹配器把开头的 `"${QWEN_CODE_CLI:-qwen}"` 读成空词,没有规则能只放行这些辅助命令;而即使是 `gh pr view *` 这样只读命令的放行规则,也会连带放行其后的命令替换、重定向和环境变量前缀。所以在默认审批模式下,每次辅助命令调用——包括推送——都会先询问;第 4 步的 `autofix_pr` 工具改变了这一点(第 6.12 节)。
|
||||
4. 循环状态:`status`、`stop`、预算,以及由之后的会话接管状态文件。
|
||||
已开始(2026-10-25):`status`(第 6.10 节)从文件列出每个监视,包括留给人处理的事项(回复计划里的 `needsHuman`,第 6.5 节);`schedule`(第 6.11 节)记录监视的任务;`/autofix-pr resume` 接管监视并重新排程该任务。`autofix_pr` 工具(第 6.12 节)运行这些辅助命令,所以 skill 的 `allowedTools` 可以点名一轮里的辅助命令,它们运行时不再询问;`unattended`(第 6.13 节)让已确认监视的修复 agent 运行命令时也不再询问。这类监视的修复 agent 在后台运行(第 6.14 节),到第 10 节的 30 分钟时限即被停止。Web Shell 在任务卡片和 PR 监视页上显示每个监视,并在所属会话里停止它(第 6.15 节)。尚未完成:第 10 节中每个失败检查的修复尝试次数预算。
|
||||
已开始(2026-10-25):`status`(第 6.10 节)从文件列出每个监视,包括留给人处理的事项(回复计划里的 `needsHuman`,第 6.5 节);`schedule`(第 6.11 节)记录监视的任务;`/autofix-pr resume` 接管监视并重新排程该任务。`autofix_pr` 工具(第 6.12 节)运行这些辅助命令,所以 skill 的 `allowedTools` 可以点名一轮里的辅助命令,它们运行时不再询问;`unattended`(第 6.13 节)让已确认监视的修复 agent 运行命令时也不再询问。这类监视的修复 agent 在后台运行(第 6.14 节),到第 10 节的 30 分钟时限即被停止。Web Shell 在任务卡片和 PR 监视页上显示每个监视,并在所属会话里停止它(第 6.15 节)。之后交付(2026-10-31):第 10 节中每个失败检查的修复推送预算。用完时该检查作为需要人工处理来回答,监视继续运行而不是停止:它仍然处理评审反馈和其他检查。
|
||||
5. 后续工作:可配置的 bot 允许名单。
|
||||
|
||||
待定问题:
|
||||
|
||||
@@ -51,10 +51,9 @@ A concurrent first import can fail closed while registration is pending; a
|
||||
retry may reuse the now-completed owned root after verifying the pinned
|
||||
identity. Revoked access is never restored by replay.
|
||||
|
||||
The selected project becomes `defaultCwdRelative`. The registered parent and
|
||||
its physical-owner lease are shared, as in existing directory selection;
|
||||
selecting a child directory does not establish filesystem isolation. The marker
|
||||
stays outside Git cleanup and the project remains the initial working directory.
|
||||
The selected project becomes `defaultCwdRelative`. Within one installation, the registered parent and its catalog-local execution lease are shared, as in existing directory selection; selecting a child directory does not establish filesystem isolation. The parent remains a navigation/worktree-layout boundary, not a global claim over its contents.
|
||||
|
||||
New imports use `imported-<receipt key>` storage IDs and private registration metadata under the Runtime state directory. They leave an existing global marker untouched and allow independent local catalogs to use the same project. Whole-storage migration, physical recovery and exclusive file-history rewind are refused for these shared imports; own-process Runtime recovery stays available. Existing `created-` receipts retain their original global marker contract and are not rewritten automatically. See the [shared-project design](2026-10-28-managed-local-shared-projects.md).
|
||||
|
||||
## Browser flow and contract
|
||||
|
||||
@@ -92,8 +91,7 @@ contract stays unchanged.
|
||||
File queries and an initial tool Turn use the imported root.
|
||||
3. Retry, restart, disabled future imports, second-project reuse and concurrent
|
||||
first imports preserve durable authority and idempotency.
|
||||
4. Missing/replaced directories, foreign markers, overlapping roots, private
|
||||
state, invalid requests and revoked grants fail without creating authority.
|
||||
4. Missing/replaced directories, missing or mismatched private registration metadata, overlapping roots, private state, invalid requests and revoked grants fail without creating authority. Legacy imports continue to reject foreign global markers; new shared imports preserve them.
|
||||
5. Disabled or remotely exposed deployments cannot browse arbitrary host paths.
|
||||
Existing empty creation, authorized directory selection and SSH still work.
|
||||
6. Run focused H2/unit tests, disposable MySQL 8.4 integration tests, Maven
|
||||
@@ -102,7 +100,7 @@ contract stays unchanged.
|
||||
|
||||
## Status and open questions
|
||||
|
||||
Implemented. Native macOS checks cover import from an empty registry, file
|
||||
The original import implementation is complete. Its earlier native macOS checks cover import from an empty registry, file
|
||||
queries, model Read tools, active-root reuse, browser retry, and restart with
|
||||
new imports disabled. Native Linux execution was not exercised locally.
|
||||
Targeted Java/MySQL and TypeScript/browser checks are recorded separately from
|
||||
@@ -114,3 +112,5 @@ The local-launcher follow-up also verifies loopback SIGNED startup, retained
|
||||
bearer/signature refusals, absolute-path browser import, an actual Read tool
|
||||
turn and restart retention on native macOS. Detailed command results and test
|
||||
artifact limitations remain in `.qwen/e2e-tests/managed-local-import.md`.
|
||||
|
||||
The shared-project follow-up has separate local validation recorded in its [design](2026-10-28-managed-local-shared-projects.md). Earlier model, browser and restart evidence above does not establish those paths for the follow-up.
|
||||
@@ -40,9 +40,9 @@ Workspace 创建根。同一根目录使用期间再次导入项目,应复用
|
||||
首次并发导入遇到尚未完成的登记时可以安全拒绝;重试时,在验证固定物理身份后,
|
||||
可以复用已经完成的自有根目录。回执重放不得恢复已撤销的授权。
|
||||
|
||||
选中的项目成为 `defaultCwdRelative`。与现有目录选择一致,登记的父目录及物理
|
||||
owner 租约由相关 Workspace 共享;选择子目录不产生文件系统隔离。标记留在
|
||||
Git 清理范围之外,项目本身作为初始工作目录。
|
||||
选中的项目成为 `defaultCwdRelative`。与现有目录选择一致,同一实例内的登记父目录及 catalog 内执行租约由相关 Workspace 共享;选择子目录不产生文件系统隔离。父目录继续作为导航/worktree 布局边界,不对其内容声明全局独占归属。
|
||||
|
||||
新导入使用 `imported-<receipt key>` 存储 ID,在 Runtime 状态目录中保存私有登记元数据;已有全局标记保持原样,独立本机 catalog 可以使用同一项目。这些共享导入拒绝整存储迁移、物理恢复和独占文件历史 rewind,本实例进程的 Runtime 恢复仍可用。已有 `created-` 回执保留原全局标记契约,不自动重写。参见[共享项目设计](2026-10-28-managed-local-shared-projects.zh-CN.md)。
|
||||
|
||||
## 浏览器流程与契约
|
||||
|
||||
@@ -72,8 +72,7 @@ Git 清理范围之外,项目本身作为初始工作目录。
|
||||
1. 未登记父目录时即可浏览并导入已有 Git 项目,得到 OWNER 权限,并在项目中启动。
|
||||
2. 文件保持原样,登记标记位于工作树之外;文件查询和首次工具 Turn 使用导入根目录。
|
||||
3. 重试、重启、关闭后续导入、复用第二个项目及首次并发导入保持持久化权限和幂等性。
|
||||
4. 目录丢失或替换、外来标记、根目录重叠、私有状态、非法请求和授权撤销均安全拒绝,
|
||||
不产生新权限。
|
||||
4. 目录丢失或替换、私有登记元数据丢失或不匹配、根目录重叠、私有状态、非法请求和授权撤销均安全拒绝,不产生新权限。旧导入继续拒绝外来全局标记,新共享导入保留它们。
|
||||
5. 未启用或向远程暴露的部署不能浏览任意主机路径。原有空目录创建、授权目录选择及
|
||||
SSH 保持可用。
|
||||
6. 执行专项 H2/单元测试、临时 MySQL 8.4 集成测试、Maven 验证、TypeScript
|
||||
@@ -82,7 +81,7 @@ Git 清理范围之外,项目本身作为初始工作目录。
|
||||
|
||||
## 状态与开放问题
|
||||
|
||||
已实现。原生 macOS 验证覆盖空登记表导入、文件查询、模型 Read 工具、运行中根目录复用、
|
||||
原始目录导入已实现。此前的原生 macOS 验证覆盖空登记表导入、文件查询、模型 Read 工具、运行中根目录复用、
|
||||
浏览器重试,以及关闭新增导入后的重启。未在本地执行原生 Linux 验收。专项 Java/MySQL
|
||||
和 TypeScript/浏览器检查与中断的 Java 全量测试分别记录在 `.qwen/e2e-tests/`。
|
||||
没有未决产品选择。普通创建回执省略未使用的导入字段,继续支持旧 Java 版本读取。
|
||||
@@ -90,3 +89,5 @@ Git 清理范围之外,项目本身作为初始工作目录。
|
||||
本机启动器后续验收还覆盖原生 macOS 上的回环 SIGNED 启动、保留的 bearer/签名
|
||||
拒绝、浏览器绝对路径导入、真实 Read 工具 Turn 和重启保留。详细命令结果与测试
|
||||
产物限制记录在 `.qwen/e2e-tests/managed-local-import.md`。
|
||||
|
||||
共享项目后续改动的本地验证单独记录于对应[设计](2026-10-28-managed-local-shared-projects.zh-CN.md)。上面的早期模型、浏览器和重启证据不能作为该后续改动已验证这些路径的证明。
|
||||
@@ -74,7 +74,7 @@ Focused tests use a real bridge/adapter and owned JSONL; only the core/scheduler
|
||||
|
||||
The MOD projection cases check finalized parts at the actual bridge, model continuation and recording consumers before checking JSON. Both nonempty-display and empty-display variants require one logical result in order between tool use and the next assistant; neither variant changes the shared serialization contract. Preserve the original incorrect JSON-only assertion failure as test-oracle evidence, not a production fix.
|
||||
|
||||
Acceptance requires matching actual Ink event families/order/IDs for supported paths, no duplicates from queue/replay/retry, no stale output into a successor, one session_start/session_end on orderly termination, and no captured live product after orderly exit. Read complete flushed lines and reject malformed JSON/ANSI. The ordinary cases use `/quit` with exit 0; the active raw-CtrlC baseline records Ink 0 and current OpenTUI 130 explicitly, without accepting arbitrary nonzero exits or changing product semantics.
|
||||
Acceptance requires matching actual Ink event families/order/IDs for supported paths, no duplicates from queue/replay/retry, no stale output into a successor, one session_start/session_end on orderly termination, and no captured live product after orderly exit. Read complete flushed lines and reject malformed JSON/ANSI. The ordinary `/quit` cases and confirmed active raw-CtrlC keyboard exits must use exit 0 after the shared cleanup drain across all three profiles. Preserve the historical Ink 0/OpenTUI 130 baseline as the mismatch being corrected; actual OS-signal termination keeps its signal code. No arbitrary nonzero exit or harness cleanup qualifies as graceful exit.
|
||||
|
||||
The preparation fixtures are independently versioned and hash-pinned. Original R3 v2 results and manifests remain immutable. Baseline collection, implementation verification and independent review are separate evidence stages. No future commit may claim the old R3 tarball contains changed source.
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ focused 使用真实 bridge/adapter 和独占 JSONL,仅控制 core/scheduler
|
||||
|
||||
MOD 投影用例先在真实 bridge、模型 continuation 和录制消费者处核对最终 parts,再检查 JSON。非空 display 与空 display 两个变体都要求逻辑结果仅出现一次,且位于 tool use 与下一条 assistant 之间;两者均不改变共享序列化合同。保留原先只检查 JSON 而预期错误的失败记录,将其归为测试断言证据,不称为生产修复。
|
||||
|
||||
验收要求支持路径匹配真实 Ink 的事件类别/顺序/ID,queue/replay/retry 不重复,旧流不污染后继,正常关闭 session_start/session_end 各一次,正常退出后没有捕获的产品活体。只解析完整 flush 的行,拒绝坏 JSON/ANSI。普通场景 `/quit` 期望 0;active raw CtrlC 基线明确记录 Ink 0 和当前 OpenTUI 130,不接受任意非零码,也不改变产品语义。
|
||||
验收要求支持路径匹配真实 Ink 的事件类别/顺序/ID,queue/replay/retry 不重复,旧流不污染后继,正常关闭 session_start/session_end 各一次,正常退出后没有捕获的产品活体。只解析完整 flush 的行,拒绝坏 JSON/ANSI。普通 `/quit` 和确认的 active raw CtrlC 键盘退出,在三个 profile 均须完成共享清理后以 0 退出。保留历史 Ink 0/OpenTUI 130 基线,作为本次修正的差异;真实 OS signal 终止保留原信号码。任意非零码和 harness 清理均不等于优雅退出。
|
||||
|
||||
准备夹具独立版本化并锁 hash。原 R3 v2 结果与 manifest 不变。基线收集、实现验证、独立审查是不同证据阶段。后续 commit 不可声称旧 R3 tarball 包含变更后的源码。
|
||||
|
||||
|
||||
@@ -63,7 +63,10 @@ Read the live composer buffer for Ctrl+D: nonempty D stays editing. C arms befor
|
||||
cleanup, so a fast second C exits. Empty D cancels a responding turn without
|
||||
pre-arming. At a pending approval, D preserves the confirmation and arms D/D exit;
|
||||
C cancels it while retaining the exit window. Reuse the existing expiry/mixed-key
|
||||
guard. Harness cleanup and exit-code differences are not graceful-exit parity.
|
||||
guard. Confirmed keyboard exits use 0 in all three renderer/runtime profiles,
|
||||
after the shared cleanup drain; actual OS-signal termination retains its signal
|
||||
code. Historical 0/130 observations remain baseline evidence, not acceptance
|
||||
of the mismatch. Harness cleanup is not a graceful exit.
|
||||
|
||||
Composer cancel registers one callback on the current live-turn sequence. Keep
|
||||
the callback, structured queue pop, draft restore and start-level C/D dispatch
|
||||
|
||||
@@ -53,8 +53,10 @@ unmount 使迟到输出失效,不能覆盖 successor 或其附件。启动前
|
||||
|
||||
Ctrl+D 读取实时 composer:非空 D 继续编辑。C 在清理前 arm,快速第二 C
|
||||
退出。响应中空 D 取消而不预先 arm。等待审批时 D 保留确认并 arm D/D 退出;
|
||||
C 取消确认且保留退出窗口。复用既有过期/混合键 guard;harness 清理及退出码
|
||||
差异不代表优雅退出对齐。
|
||||
C 取消确认且保留退出窗口。复用既有过期/混合键 guard。确认的键盘退出在
|
||||
三个 renderer/runtime profile 均须完成共享清理后以 0 退出;真实 OS signal
|
||||
终止保留原信号码。历史 0/130 观测继续作为基线证据,不代表接受差异;
|
||||
harness 清理不等于优雅退出。
|
||||
|
||||
Composer 取消在当前 live-turn sequence 注册一个回调。回调、结构化队列
|
||||
pop、草稿恢复和入口 C/D 分派整体保留本批。Abort 的生成器收尾后,将返还
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
# Workspace skill installer limits
|
||||
|
||||
[English](2026-10-21-workspace-skill-installer-limits.md) |
|
||||
[简体中文](2026-10-21-workspace-skill-installer-limits.zh-CN.md)
|
||||
|
||||
## Status
|
||||
|
||||
Implemented by this pull request, initially proposed on 2026-10-21.
|
||||
The baseline sections describe the previous limits; the solution below
|
||||
describes this implementation. Merge and cross-platform validation are pending.
|
||||
|
||||
## Problem
|
||||
|
||||
The native skill installer rejects packages that are ordinary skills. One
|
||||
file over 2 MiB fails. The 129th file fails. GitHub and local-folder
|
||||
installs fail at 6 MiB even though those sources never enter a JSON body.
|
||||
|
||||
Two constraints are coupled, and only one of them is about HTTP:
|
||||
|
||||
- **Transport.** `POST /workspace/skills/install` sends a ZIP as
|
||||
`source.contentBase64` inside JSON. `express.json({ limit: '10mb' })` in
|
||||
`packages/cli/src/serve/server/error-handlers.ts` parses that body.
|
||||
`[email protected]` maps `mb` to `1 << 20`, so the ceiling is 10,485,760 bytes.
|
||||
A body past it becomes `413` with `Request body too large (max 10 MB)`
|
||||
and never reaches the installer.
|
||||
- **Memory.** `SkillPackageFile.content` is a `Buffer`. Zip, GitHub, and
|
||||
folder installs all accumulate those buffers, then write them to the
|
||||
staging directory. The 6 MiB uncompressed cap is that heap budget. It
|
||||
also stops a compressed archive from expanding without bound. GitHub and
|
||||
folder installs pay it even though their bytes are already on the wire
|
||||
or on disk.
|
||||
|
||||
`docs/design/web-shell/web-shell-skill-manager-page.md` describes file
|
||||
count, individual size, aggregate size, path depth, and path length as
|
||||
bounded below the daemon JSON parser. That sentence matches the original
|
||||
installer from commit `0ecba4b3c70` (PR #7018). It is the wrong bound for
|
||||
GitHub and folder once the package is no longer held in memory. The 2 MiB
|
||||
per-file cap and the 128-file cap have no finer written rationale.
|
||||
|
||||
A second skill download path already allows much larger fetches. It is not
|
||||
this installer, and its decompressed budget is not safe to copy while this
|
||||
process still buffers every file. See Baseline before this change.
|
||||
|
||||
## Baseline before this change
|
||||
|
||||
`packages/cli/src/serve/workspace-skill-management.ts` uses one budget for
|
||||
every source:
|
||||
|
||||
| Constant | Value | What it gates |
|
||||
| ----------------- | ----- | ----------------------------------------------------------------------------- |
|
||||
| `MAX_FILES` | 128 | Zip entries, GitHub files, folder files, and the normalized package |
|
||||
| `MAX_FILE_BYTES` | 2 MiB | One zip entry, one GitHub response, one folder file, and each normalized file |
|
||||
| `MAX_TOTAL_BYTES` | 6 MiB | Decoded base64, uncompressed zip bytes, GitHub totals, and folder totals |
|
||||
| `MAX_PATH_LENGTH` | 512 | Relative paths and the GitHub directory |
|
||||
| `MAX_PATH_DEPTH` | 16 | Relative paths, zip layout, GitHub walk, and folder walk |
|
||||
|
||||
`decodeBase64` rejects a decoded archive larger than `MAX_TOTAL_BYTES`.
|
||||
`filesFromZip` then reads every entry into memory (`readZipEntry`
|
||||
concatenates chunks) and applies the same per-file and total caps again.
|
||||
`fromBuffer` keeps the compressed archive alive for the whole read.
|
||||
`downloadGitHubDirectory` pushes each `fetchBytes` buffer into
|
||||
`state.files`. The git sparse-checkout fallback checks the tree out under
|
||||
`os.tmpdir()` and then `filesFromFolder` reads it back into buffers.
|
||||
`filesFromFolder` `readFile`s every file before `installWorkspaceSkill`
|
||||
writes the buffers into the existing staging directory
|
||||
(`.skills-<name>.installing-*` under the skills parent). Peak heap for a
|
||||
legal package is the whole uncompressed tree, plus the zip buffer on the
|
||||
zip path, plus the JSON body (up to 10 MiB) until the request ends.
|
||||
|
||||
`packages/web-shell/client/components/skills/SkillInstallDialog.tsx` sets
|
||||
`MAX_SKILL_ZIP_BYTES` to 6 MiB and refuses the file before upload. Both
|
||||
catalogs in `packages/web-shell/client/i18n.tsx` say:
|
||||
|
||||
- `skills.install.error.zipTooLarge`: `The ZIP file must not exceed 6 MB.`
|
||||
- Chinese catalog: `ZIP 文件不能超过 6 MB。`
|
||||
|
||||
The dialog maps every server `skill_package_too_large` onto that ZIP
|
||||
sentence, including a GitHub or folder package that exceeded the
|
||||
uncompressed cap and a single file that exceeded 2 MiB.
|
||||
|
||||
Size failures use `skill_package_too_large` at HTTP 413. File-count
|
||||
failures use `invalid_skill_package` at HTTP 400 (`Skill package has too
|
||||
many files`, `Skill ZIP contains too many files`, or `Skill package has
|
||||
an invalid file count` for an empty package). Daemon messages do not
|
||||
include the numeric caps.
|
||||
|
||||
Tests in `workspace-skill-management.test.ts` lock the old ceilings:
|
||||
`stops reading an oversized GitHub Skill file` (2 MiB plus one byte),
|
||||
`reports an expanded ZIP entry over the limit as too large` (one entry of
|
||||
2 MiB plus one byte), and `reports a ZIP whose total expanded content is
|
||||
over the limit` (four 1,600,000-byte files, about 6.4 MiB). The skill
|
||||
manager design calls these ZIP-bomb limits.
|
||||
|
||||
`packages/cli/src/acp-integration/skill-source-download.ts` is a different
|
||||
feature. It allows 100 MiB compressed
|
||||
(`MAX_SKILL_DOWNLOAD_BYTES`), 500 MiB decompressed
|
||||
(`MAX_SKILL_DECOMPRESSED_BYTES`), 2000 API files
|
||||
(`MAX_SKILL_API_FILE_COUNT`), and depth 16
|
||||
(`MAX_SKILL_API_DIR_DEPTH`). Inflation is aborted once the cap is crossed,
|
||||
but a legal archive is still `Buffer.concat`'d, and the GitHub API walker
|
||||
still retains every file buffer. Those 500 MiB can sit on the daemon heap.
|
||||
This installer must not adopt that decompressed number.
|
||||
|
||||
Codex, for citation only: `openai/codex` `install-skill-from-github.py`
|
||||
has no compressed, uncompressed, per-file, or file-count cap. It copies a
|
||||
repo zip or a git sparse checkout onto disk and rejects path escape,
|
||||
symlinks, bad skill names, an existing destination, and a missing
|
||||
`SKILL.md`. Plugin bundles in `codex-rs/core-plugins` allow 100 MiB
|
||||
remote download and 512 MiB extracted (PR #36485, 2026-08-01, raised from
|
||||
50/250). Share and npm archives are 50 MiB, npm extracted 250 MiB, with
|
||||
no per-file or entry-count cap. The OpenAI plugin submission portal
|
||||
allows a zip up to 100 MB, a member up to 100 MiB, 5000 entries,
|
||||
uncompressed size up to 512 MiB, and path depth 20. Its
|
||||
`skill_bundle_too_large` numeric cap is not published. Those installers
|
||||
do not hold the extracted tree in the process heap. Matching their
|
||||
unlimited skill installer, or their 512 MiB extract cap, is not available
|
||||
to a path that still buffers every file.
|
||||
|
||||
## Goals
|
||||
|
||||
- Split the ZIP transport ceiling from the on-disk package ceiling.
|
||||
- Raise the per-file and file-count ceilings to values that admit a skill
|
||||
with real assets and a real reference tree.
|
||||
- Keep zip-bomb, path, symlink, and staging rollback behavior.
|
||||
- Avoid retaining the expanded package in the installer heap; retain only
|
||||
streaming buffers and the separately bounded manifest read.
|
||||
- Tell Web Shell the new ZIP ceiling in both languages, and stop
|
||||
describing every size failure as a 6 MB ZIP.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new upload protocol. The JSON body can carry a useful source ZIP
|
||||
once expansion is no longer capped at the compressed size. A
|
||||
binary-heavy package that does not fit the transport ceiling uses the
|
||||
existing GitHub or folder source.
|
||||
- No change to `express.json({ limit: '10mb' })` or to the global 10 MiB
|
||||
body limit. That limit covers every JSON route.
|
||||
- No change to `packages/cli/src/acp-integration/skill-source-download.ts`.
|
||||
A shared constant would copy a heap budget this path is refusing.
|
||||
- No user setting, environment variable, or per-workspace override.
|
||||
- No change to `MAX_PATH_LENGTH` (512) or `MAX_PATH_DEPTH` (16). Depth
|
||||
already matches `MAX_SKILL_API_DIR_DEPTH`.
|
||||
- Preserve skill-name rules, `SKILL.md` name matching, symlink rejection,
|
||||
single-root stripping, staging rollback, and API-then-git fallback
|
||||
behavior while adapting their implementation to disk staging. Git
|
||||
timeouts remain 60 s for clone and 30 s for sparse-checkout.
|
||||
- No rewrite of `docs/design/web-shell/web-shell-skill-manager-page.md`.
|
||||
That page has no Chinese twin; editing one sentence would require a
|
||||
full translation. This document is the limit spec. The old sentence
|
||||
describes the installer that shipped in #7018.
|
||||
- No concurrency limit for installs, and no switch of the GitHub path
|
||||
onto a single archive download.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
Replace `MAX_TOTAL_BYTES` with two ceilings. Raise the per-file and
|
||||
file-count ceilings. Stream each accepted file into the staging directory
|
||||
the installer already creates, and apply the package ceiling to bytes
|
||||
written there.
|
||||
|
||||
```text
|
||||
MAX_ZIP_ARCHIVE_BYTES = 7_864_071
|
||||
MAX_FILE_BYTES = 32 * 1024 * 1024 // 33_554_432
|
||||
MAX_PACKAGE_BYTES = 64 * 1024 * 1024 // 67_108_864
|
||||
MAX_FILES = 2000
|
||||
MAX_PATH_LENGTH = 512 // unchanged
|
||||
MAX_PATH_DEPTH = 16 // unchanged
|
||||
```
|
||||
|
||||
`MAX_SKILL_ZIP_BYTES` in the Web Shell dialog equals
|
||||
`MAX_ZIP_ARCHIVE_BYTES`. The dialog cannot import the CLI module. Duplicate
|
||||
the literal. Do not add a shared package.
|
||||
|
||||
### Transport ceiling
|
||||
|
||||
A compliant body is compact `JSON.stringify` output:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "<256 chars>",
|
||||
"scope": "workspace",
|
||||
"source": { "type": "zip", "contentBase64": "<base64>" }
|
||||
}
|
||||
```
|
||||
|
||||
`name` is at most `MAX_WORKSPACE_SKILL_NAME_LENGTH` (256) characters from
|
||||
`[A-Za-z0-9._-]`, so it does not JSON-escape. `scope` `"workspace"` is the
|
||||
longer scope. `contentBase64` uses the standard alphabet, which does not
|
||||
JSON-escape. Key order does not change the length. With an empty
|
||||
`contentBase64` that envelope is 330 bytes.
|
||||
|
||||
```text
|
||||
JSON_BODY_LIMIT_BYTES = 10 * 1024 * 1024 // 10_485_760
|
||||
ENVELOPE_BYTES = 330
|
||||
BASE64_BUDGET = 10_485_760 - 330 // 10_485_430
|
||||
MAX_ZIP_ARCHIVE_BYTES = 3 * floor(10_485_430 / 4) // 7_864_071
|
||||
```
|
||||
|
||||
The encoded body is 330 + 10,485,428 = 10,485,758 bytes, two bytes under
|
||||
the parser. The next raw byte encodes to four more base64 characters
|
||||
(10,485,762 bytes) and is rejected by the parser before `decodeBase64`.
|
||||
`decodeBase64` enforces `MAX_ZIP_ARCHIVE_BYTES` on the decoded buffer so a
|
||||
short name cannot sneak a larger archive past the installer. The client
|
||||
checks `file.size` against the same number before it builds base64.
|
||||
|
||||
That is about 7.50 MiB, up from 6 MiB. The useful ZIP change is not this
|
||||
bump. A text-heavy archive under 7,864,071 bytes may expand up to
|
||||
`MAX_PACKAGE_BYTES` on disk. An incompressible archive cannot, and GitHub
|
||||
or folder is the source for that package.
|
||||
|
||||
### On-disk package ceiling
|
||||
|
||||
Create the existing staging directory before reading the source. For each
|
||||
accepted file, validate the relative path (unchanged rules), then write it
|
||||
and drop the body:
|
||||
|
||||
- **ZIP.** Keep `fromBuffer` on the decoded archive. That buffer is at most
|
||||
`MAX_ZIP_ARCHIVE_BYTES`. Pipe each entry to a file in staging. Count
|
||||
bytes. `destroy` the entry stream when `entry.uncompressedSize` or the
|
||||
bytes read would pass `MAX_FILE_BYTES` or `MAX_PACKAGE_BYTES`. Do not
|
||||
`Buffer.concat` the entry.
|
||||
- **Folder.** `lstat`, reject symlinks and special files as today, then
|
||||
`copyFile` into staging. Do not `readFile` the payload. If the size
|
||||
changes across the copy, reject, as today's length check does.
|
||||
- **GitHub API.** Pipe `fetchBytes` to the staging file. Honor
|
||||
`content-length` and the streamed total against `MAX_FILE_BYTES`, and
|
||||
the running total against `MAX_PACKAGE_BYTES`. Cancel the body when the
|
||||
cap is crossed.
|
||||
- **Git sparse checkout.** Keep the temp checkout. Copy it into staging
|
||||
with the folder walk. Delete the temp checkout in the existing `finally`.
|
||||
Do not read the checkout back into an array of buffers.
|
||||
|
||||
Skip `__MACOSX`, `.DS_Store`, and `._*` entries before they consume
|
||||
`MAX_FILES` or `MAX_PACKAGE_BYTES`, and do not install them. Today the zip
|
||||
pre-check counts that junk and `normalizePackageFiles` drops it afterwards.
|
||||
The caps then describe installed files.
|
||||
|
||||
After the tree is on disk, strip a single enclosing directory with renames
|
||||
when there is no root `SKILL.md` and every file shares one root. Require
|
||||
root `SKILL.md`. Parse `SKILL.md` from the staged file, as today. On any
|
||||
failure, remove staging and restore the backup, as today. Keep calling
|
||||
`assertGenerationOpen` around the writes so a closed generation does not
|
||||
leave a skill directory.
|
||||
|
||||
During staging, retained file bodies do not grow with `MAX_PACKAGE_BYTES`
|
||||
(the later manifest read is accounted for under Risks):
|
||||
|
||||
| Source | What stays in the heap |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| ZIP | The JSON body (≤ 10 MiB) until the handler returns, the archive buffer (≤ 7,864,071 bytes), and one stream chunk |
|
||||
| GitHub API | The directory listing plus one stream chunk |
|
||||
| Git checkout | Nothing from the file bodies. Git and `copyFile` use the kernel |
|
||||
| Folder | Nothing from the file bodies. `copyFile` uses the kernel |
|
||||
|
||||
A path set for duplicate detection is required. It holds strings, not
|
||||
contents.
|
||||
|
||||
### Per-file and file-count ceilings
|
||||
|
||||
`MAX_FILE_BYTES` is 32 MiB. `MAX_FILES` is 2000. Both are on-disk limits
|
||||
enforced before or during the streamed write. They are not heap
|
||||
reservations.
|
||||
|
||||
## Decisions
|
||||
|
||||
**`MAX_ZIP_ARCHIVE_BYTES = 7_864_071`.** This is the largest raw ZIP that
|
||||
fits in the 10 MiB JSON body with the worst-case compliant envelope, as
|
||||
calculated above. Rounding down to 7 MiB would leave usable bytes unused.
|
||||
Rounding up to 7.5 MiB (7,864,320) would make a max-length skill name fail
|
||||
in the parser with the generic 10 MB error. The client and the server use
|
||||
the same integer so the file picker fails before upload.
|
||||
|
||||
**`MAX_PACKAGE_BYTES = 64 MiB`.** Six MiB is the old heap budget, applied
|
||||
even to sources that are already on disk. Once files are streamed into
|
||||
staging, this ceiling is disk. 64 MiB covers `SKILL.md`, scripts, and more
|
||||
than one multi-megabyte asset (documents, a wasm module, a small database,
|
||||
images). It stays in the same tens-of-megabytes band as the daemon's 50 MiB
|
||||
binary upload (`MAX_UPLOAD_BYTES` in `packages/cli/src/serve/fs/policy.ts`)
|
||||
without taking that constant: the upload route buffers the body, and this
|
||||
path must not. 100 MiB / 500 MiB / 512 MiB from the other skill downloader
|
||||
and from Codex plugin extraction are plugin-sized trees and, on the other
|
||||
Qwen path, still a heap budget. They are not the skill staging budget.
|
||||
|
||||
**`MAX_FILE_BYTES = 32 MiB`.** Two MiB rejects one ordinary asset even when
|
||||
the rest of the package is tiny. 32 MiB admits one PDF, wasm module,
|
||||
SQLite file, font, or small binary helper. It is half of the package
|
||||
ceiling, so a second large asset has to fit in what remains. The write is
|
||||
a stream or `copyFile`. The process must not allocate a 32 MiB `Buffer` for
|
||||
the file. There is no per-file cap on `skill-source-download.ts` to align
|
||||
with; copying "no cap" would let one entry grow toward the package ceiling
|
||||
without a separate early reject, and the 2 MiB constant needs a replacement
|
||||
that is still a single-entry zip-bomb guard.
|
||||
|
||||
**`MAX_FILES = 2000`.** A reference tree plus scripts passes 128 files
|
||||
while the byte total is still small. 2000 is `MAX_SKILL_API_FILE_COUNT`,
|
||||
the in-repo cap for the same kind of GitHub directory walk. It is safe
|
||||
here because the installer retains paths, not 2000 buffers. The portal's
|
||||
5000 and Codex's unlimited skill copy are not used. Empty packages still
|
||||
fail `invalid_skill_package`. Metadata files do not count.
|
||||
|
||||
**Stream into the existing staging directory.** Raising `MAX_TOTAL_BYTES`
|
||||
in place and still filling `SkillPackageFile[]` would pin the new package
|
||||
ceiling on the daemon heap. At 64 MiB that is most of a small worker; at
|
||||
the other downloader's 500 MiB it is the failure mode this design refuses.
|
||||
The staging directory, rollback, and `SKILL.md` parse-from-disk already
|
||||
exist. The implementation writes there earlier and deletes the in-memory
|
||||
package array. No new type of temp directory and no new route.
|
||||
|
||||
**Retry and cleanup isolation.** Before a Contents API failure falls back
|
||||
to git, empty the same staging directory and reset paths, file count, and
|
||||
byte count together. Staging and backup names embed the owning pid, so a
|
||||
second daemon reclaims a crashed run's artifacts but leaves a live peer's
|
||||
in place; the in-process registry covers the window from before the
|
||||
staging directory exists to after commit. Artifact cleanup matches the
|
||||
complete suffix after the skill-specific prefix, so installing `foo`
|
||||
cannot sweep the staging or backup of `foo.backup-1` or
|
||||
`foo.installing-abc123`. Requested names are rejected only when the sweep
|
||||
could reach them: a leading-dot name ending in `.backup-` or
|
||||
`.installing-` followed by `[A-Za-z0-9-]`, or a name ending in a
|
||||
two-number `backup`/`installing` suffix that the skill loaders already
|
||||
skip. Names such as `release.installing-notes` or `db.backup-2024` carry
|
||||
no leading dot and stay installable. This does not introduce
|
||||
cross-process install locks; the route mutex keys on the resolved skills
|
||||
directory so aliased scopes serialize.
|
||||
|
||||
**Keep one size error code.** `skill_package_too_large` (413) still covers
|
||||
the archive, one file, and the package total. Messages stay specific.
|
||||
File count gets its own code so Web Shell can translate it. See
|
||||
Implementation scope.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The parser ceiling is 10,485,760 bytes, inclusive. `body-parser` rejects
|
||||
only a body that exceeds `limit`.
|
||||
- The transport formula assumes compact JSON and a valid skill name. A
|
||||
client that pretty-prints, adds fields, or sends a name that
|
||||
JSON-escapes can still receive the generic parser 413. The official
|
||||
client uses compact `JSON.stringify` of `{ name, scope, source }`, but
|
||||
does not enforce the name-length ceiling before submission. Overlong
|
||||
names can therefore hit the parser before route validation.
|
||||
- `yauzl`'s `fromBuffer` requires the whole archive. That cost is capped
|
||||
by `MAX_ZIP_ARCHIVE_BYTES` and is acceptable. Do not add a temp-file ZIP
|
||||
reader in this change.
|
||||
- GitHub Contents API listings stop at the API's 1000-entry page maximum.
|
||||
A listing at that length is treated as truncated and re-read through the
|
||||
existing git fallback, so enumeration completeness does not depend on the
|
||||
API walk where the git transport can authenticate: public repositories, or
|
||||
hosts with git credentials configured. For a private repository reached
|
||||
with only the configured API token, a listing at or above 1000 entries has
|
||||
no working transport until the deferred fallback authentication lands (see
|
||||
the Git credential bullet below). A 2000-file API walk can still be slow
|
||||
or rate-limited. The new
|
||||
file cap is a ceiling, not a completion-time guarantee. Both the SDK
|
||||
request and Web Shell action currently time out after 30 s. A slow
|
||||
install may finish on the daemon after the client reports a timeout;
|
||||
cancellation and timeout changes are follow-up work.
|
||||
- Git fallback keeps the existing Git environment and credential helpers. The
|
||||
configured API token is not injected into Git subprocesses. Dedicated private
|
||||
repository fallback authentication is deferred: it needs host scoping,
|
||||
inherited configuration preservation, and lazy-fetch coverage. This deferral
|
||||
is a correctness boundary for the ≥1000-entry listing path above, not just an
|
||||
enhancement.
|
||||
- Web Shell and the CLI do not share a module for this constant. The two
|
||||
literals must be kept equal by review.
|
||||
- ZIP compression ratio is not limited separately. The archive and
|
||||
expanded-byte ceilings apply independently, including to highly
|
||||
compressible packages.
|
||||
|
||||
## Risks
|
||||
|
||||
**Heap, if the stream is skipped.** Implementing only the new constants
|
||||
and leaving `SkillPackageFile.content` arrays in place would hold up to
|
||||
64 MiB of file bodies, plus up to 7,864,071 bytes of ZIP, plus the 10 MiB
|
||||
JSON body, per in-flight install. Concurrent installs multiply that. This
|
||||
design does not paper over that cost. The implementation is wrong if the
|
||||
package still resides in those buffers at the new ceiling.
|
||||
|
||||
**ZIP heap that remains.** A ZIP install still holds the JSON text and the
|
||||
decoded archive until the handler finishes. Together those are at most
|
||||
the 10 MiB JSON body plus the 7,864,071-byte archive, plus one stream
|
||||
chunk. Manifest validation additionally reads the whole `SKILL.md`
|
||||
(up to 32 MiB) into a string, as do subsequent skill-loader reads.
|
||||
Streaming removes retained package bodies, not this manifest cost.
|
||||
Do not describe any source as allocation-free.
|
||||
|
||||
**Disk.** Staging can hold up to 64 MiB, then the rename makes that the
|
||||
installed skill. Replacing an existing skill briefly keeps a backup of the
|
||||
old tree as well. The git fallback briefly keeps the temp checkout and the
|
||||
staging copy until `finally` deletes the checkout. The checkout itself
|
||||
is not bounded by the installer caps: git materializes it before the
|
||||
copy validates sizes. The ≥1000-entry page-limit trigger routes the
|
||||
largest directories through this path deterministically, so the unbounded
|
||||
checkout cost is no longer limited to 429/5xx retries. Do not treat
|
||||
128 MiB as a disk-usage bound.
|
||||
A failed install must still delete staging. A partial entry that crossed
|
||||
the cap lives only inside that staging directory.
|
||||
|
||||
**Rejected installs may leave the base directory.** The skills base
|
||||
directory is created before the source is acquired, so a missing source
|
||||
folder, a GitHub 404, or a ZIP that fails to decode fails after
|
||||
`<workspace>/.qwen/skills` (or the global equivalent) exists. Only the
|
||||
invalid-base64 ZIP shape is rejected before the directory is created.
|
||||
The leftover empty directory is deliberate: staging must live beside the
|
||||
destination so the final rename stays on one filesystem.
|
||||
|
||||
**Misleading UI if the mapping is left alone.** Today's ZIP sentence is
|
||||
already wrong for GitHub, folder, and per-file failures. Pointing it at
|
||||
7,864,071 bytes without a separate package sentence would keep that bug.
|
||||
|
||||
**Generation close.** `does not create Skill directories after the
|
||||
generation closes` throws on the third `assertGenerationOpen` and expects
|
||||
no `.qwen` directory. Reordering writes must not leave `.qwen` or a
|
||||
staging directory behind. Update the check index if the call sequence
|
||||
changes; keep the observable.
|
||||
|
||||
## Implementation scope
|
||||
|
||||
Touch only:
|
||||
|
||||
- `packages/cli/src/serve/workspace-skill-management.ts`
|
||||
- `packages/cli/src/serve/workspace-skill-management.test.ts`
|
||||
- `packages/cli/src/serve/workspace-skill-management-recovery.test.ts`
|
||||
(new; Contents API failure retry with a staging reset, and artifact-prefix
|
||||
collision isolation)
|
||||
- `packages/web-shell/client/components/skills/SkillInstallDialog.tsx`
|
||||
- `packages/web-shell/client/components/skills/SkillInstallDialog.test.tsx`
|
||||
(error-code rows for the new size and file-count codes)
|
||||
- `packages/web-shell/client/i18n.tsx` (English and Chinese catalogs)
|
||||
|
||||
Do not touch `error-handlers.ts`, `workspace-skills.ts` (the route passes
|
||||
`contentBase64` through), `skill-source-download.ts`, SDK types, or the
|
||||
global JSON limit.
|
||||
|
||||
### Behavior
|
||||
|
||||
- Decoded ZIP larger than 7,864,071 bytes: `413 skill_package_too_large`,
|
||||
message `Skill ZIP exceeds the allowed archive size`.
|
||||
- One file or zip entry over 32 MiB: `413 skill_package_too_large`. Keep
|
||||
the current sentences (`Skill file is too large`, `Skill file is too
|
||||
large: <path>`, `Skill ZIP entry is too large: <name>`, `Skill ZIP entry
|
||||
exceeds the allowed size`).
|
||||
- Installed bytes over 64 MiB: `413 skill_package_too_large`. Keep
|
||||
`Skill package exceeds the allowed size` and `Skill ZIP expands beyond
|
||||
the allowed size`.
|
||||
- More than 2000 installed files: `400 skill_too_many_files`. Messages:
|
||||
`Skill package has too many files (max 2000)` and `Skill ZIP contains
|
||||
too many files (max 2000)`.
|
||||
- A staging write fault (an errno in `ENOSPC`, `EDQUOT`, `EROFS`, `EIO`,
|
||||
`EMFILE`, `ENFILE`, `EBUSY`, `EACCES`, `EPERM`) raised by a write into
|
||||
the staging directory: `500 skill_staging_failed`. The folder copy
|
||||
keeps the ambiguous errnos honest: Node reports both the source `path`
|
||||
and the target `dest` for every `copyFile` failure, so `EACCES` and
|
||||
`EPERM` map to 500 only while the source is still readable; `EIO` and
|
||||
the other read-or-write errnos keep the per-source 400/502 mapping.
|
||||
Direct staging writes (`open`, `write`, `mkdir`) map `EACCES` and
|
||||
`EPERM` unconditionally. A staged file that disappeared before
|
||||
finalize is also `500 skill_staging_failed`.
|
||||
- Empty package, bad paths, duplicates, and excess depth: unchanged
|
||||
`invalid_skill_package` messages and status 400.
|
||||
- A ZIP entry that would make one path both a file and a directory, in
|
||||
either order: `400 invalid_skill_package`, message
|
||||
`Skill package has conflicting file and directory entries: <path>` with
|
||||
the later entry's path.
|
||||
- A folder or ZIP whose expanded size is over 6 MiB and within the new
|
||||
caps installs. A file of 2 MiB plus one byte installs.
|
||||
- Path length, depth, symlink, encryption, absolute path, and `..`
|
||||
rejection are unchanged.
|
||||
- Web Shell file picker: `file.size > 7_864_071` shows
|
||||
`skills.install.error.zipTooLarge` and does not upload.
|
||||
- Web Shell server `skill_package_too_large`: show
|
||||
`skills.install.error.packageTooLarge`, not the ZIP sentence.
|
||||
- Web Shell server `skill_too_many_files`: show
|
||||
`skills.install.error.tooManyFiles`.
|
||||
- Other `invalid_skill_package` results keep
|
||||
`skills.install.error.invalidPackage`.
|
||||
|
||||
### Copy
|
||||
|
||||
`skills.install.error.zipTooLarge`:
|
||||
|
||||
- English: `The ZIP file must not exceed 7,864,071 bytes.`
|
||||
- Chinese: `ZIP 文件不能超过 7,864,071 字节。`
|
||||
|
||||
`skills.install.error.packageTooLarge`:
|
||||
|
||||
- English: `The skill exceeds the installer limit of 32 MiB per file or 64 MiB total.`
|
||||
- Chinese: `技能超过安装限制:单文件最大 32 MiB,合计最大 64 MiB。`
|
||||
|
||||
`skills.install.error.tooManyFiles`:
|
||||
|
||||
- English: `The skill package has too many files (max 2000).`
|
||||
- Chinese: `技能包文件过多(最多 2000 个)。`
|
||||
|
||||
Remove both current sentences that say `6 MB`. Do not describe
|
||||
7,864,071 bytes as 7.5 MiB in the UI: 7.5 MiB is 7,864,320 and would
|
||||
accept 249 bytes the server rejects.
|
||||
|
||||
A short comment next to `MAX_ZIP_ARCHIVE_BYTES` records the envelope
|
||||
formula; the number is otherwise unexplained. Two further why-comments are
|
||||
budgeted: one above the Contents-API page limit and one on the
|
||||
`liveInstallArtifacts` invariant. No other new comments.
|
||||
|
||||
## Validation
|
||||
|
||||
Update `packages/cli/src/serve/workspace-skill-management.test.ts` and the
|
||||
new `packages/cli/src/serve/workspace-skill-management-recovery.test.ts`.
|
||||
Do not run the full suite for the design; the implementation PR runs both
|
||||
files.
|
||||
|
||||
- Assert `MAX_ZIP_ARCHIVE_BYTES` equals the formula above, and that a
|
||||
worst-case envelope plus the base64 of that many bytes is ≤ 10,485,760,
|
||||
while one more raw byte is over. Export the constant for the assertion,
|
||||
or assert it from the module if it is already exported. Do not export it
|
||||
for configuration.
|
||||
- Flip `reports an expanded ZIP entry over the limit as too large`: an
|
||||
entry of 2 MiB plus one byte installs.
|
||||
- Flip `reports a ZIP whose total expanded content is over the limit`:
|
||||
four 1,600,000-byte files install.
|
||||
- Replace `stops reading an oversized GitHub Skill file`: a body of 2 MiB
|
||||
plus one byte without `content-length` is under the new per-file cap.
|
||||
A response whose `content-length` is `MAX_FILE_BYTES + 1` returns 413
|
||||
and does not require a 32 MiB body. One streamed body of
|
||||
`MAX_FILE_BYTES + 1` bytes still returns 413 and cancels the read. That
|
||||
single test may allocate 32 MiB plus one byte. Do not also allocate
|
||||
64 MiB.
|
||||
- Folder: a sparse file (`fs.truncate`) of `MAX_FILE_BYTES + 1` returns 413. Sparse files whose sizes sum to `MAX_PACKAGE_BYTES + 1` return 413.
|
||||
Neither test should allocate those buffers in the test process.
|
||||
- Folder or ZIP: 129 small files install. 2001 small files return
|
||||
`skill_too_many_files`.
|
||||
- A file of 2 MiB plus one byte in a folder installs.
|
||||
- Existing symlink, traversal, manifest, and rollback tests stay green.
|
||||
- `does not create Skill directories after the generation closes` still
|
||||
leaves no skill directory. Adjust its check index if needed.
|
||||
|
||||
No existing Web Shell test asserts the 6 MB string
|
||||
(`SkillsManagerPage.test.tsx` does not). The UI change is the two i18n
|
||||
catalogs, the dialog constant, and the error-code rows added to
|
||||
`SkillInstallDialog.test.tsx`. Do not add a browser suite for this.
|
||||
|
||||
The implementation PR runs the focused installer tests, Web Shell
|
||||
typecheck for the dialog and i18n edits, and `npm run typecheck` for the
|
||||
touched packages. It does not need a new E2E plan beyond those tests: the
|
||||
behavior is the same install flow with new ceilings. Record that in the PR
|
||||
test notes.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. The four constants above are the ones in
|
||||
`workspace-skill-management.ts`. `MAX_TOTAL_BYTES` is gone.
|
||||
`MAX_PATH_LENGTH` and `MAX_PATH_DEPTH` are unchanged.
|
||||
2. `MAX_SKILL_ZIP_BYTES` is 7,864,071. Both i18n catalogs use the strings
|
||||
in this document. Neither catalog still contains `6 MB` for this error.
|
||||
3. A reviewer can recompute 7,864,071 from 10 MiB minus the 330-byte
|
||||
envelope. The global JSON limit is still `10mb`.
|
||||
4. GitHub and folder installs write into the existing staging directory
|
||||
without retaining a `SkillPackageFile[]` of file bodies. ZIP expansion
|
||||
pipes entry streams to files. The archive buffer remains, bounded by
|
||||
`MAX_ZIP_ARCHIVE_BYTES`.
|
||||
5. A package over 6 MiB and within 64 MiB, 32 MiB per file, and 2000 files
|
||||
installs from folder and from ZIP. Crossing any one of those ceilings
|
||||
returns the status and code in this document and leaves no staging
|
||||
directory and no partial skill.
|
||||
6. Symlinks, path escape, depth, path length, manifest checks, and
|
||||
rollback behave as they do now.
|
||||
7. `skill-source-download.ts` and `error-handlers.ts` have no diff.
|
||||
8. The old skill-manager design is untouched. Where it still says every
|
||||
size is bounded below the JSON parser, this document wins for the
|
||||
installer limits.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. The ceilings and the decision to stream into the existing staging
|
||||
directory are settled. A follow-up may move GitHub installs onto one
|
||||
archive download if the contents-API walk is too slow at the new file
|
||||
cap. That follow-up is not required for this change.
|
||||
@@ -0,0 +1,486 @@
|
||||
# 工作区技能安装器大小限制
|
||||
|
||||
[English](2026-10-21-workspace-skill-installer-limits.md) |
|
||||
[简体中文](2026-10-21-workspace-skill-installer-limits.zh-CN.md)
|
||||
|
||||
## 状态
|
||||
|
||||
2026-10-21 提出,由本 pull request 实现。基线章节描述修改前的限制,
|
||||
下文方案描述本次实现。合并与跨平台验证尚未完成。
|
||||
|
||||
## 问题
|
||||
|
||||
原生技能安装器会拒绝普通技能包。单个文件超过 2 MiB 即失败。第 129 个
|
||||
文件即失败。GitHub 与本地文件夹安装在 6 MiB 处失败,即使这两个来源根本
|
||||
不进入 JSON 请求体。
|
||||
|
||||
今天有两类约束被绑在一起,其中只有一类与 HTTP 有关:
|
||||
|
||||
- **传输。** `POST /workspace/skills/install` 把 ZIP 放在 JSON 的
|
||||
`source.contentBase64` 里。`packages/cli/src/serve/server/error-handlers.ts`
|
||||
使用 `express.json({ limit: '10mb' })` 解析该请求体。`[email protected]` 把
|
||||
`mb` 映射为 `1 << 20`,因此上限是 10,485,760 字节。超出的请求变成
|
||||
`413`,正文为 `Request body too large (max 10 MB)`,到不了安装器。
|
||||
- **内存。** `SkillPackageFile.content` 是 `Buffer`。ZIP、GitHub 和文件夹
|
||||
都会把这些 buffer 积攒起来,再写入暂存目录。6 MiB 的未压缩上限就是这份
|
||||
堆预算,同时也阻止压缩包无界展开。GitHub 和文件夹的字节已经在网络或
|
||||
磁盘上,却仍要支付这笔预算。
|
||||
|
||||
`docs/design/web-shell/web-shell-skill-manager-page.md` 把文件数、单文件
|
||||
大小、总大小、路径深度和路径长度描述为低于 daemon JSON 解析器上限。这句话
|
||||
符合 commit `0ecba4b3c70`(PR #7018)引入的原始安装器。包不再整包留在内存
|
||||
里之后,它不再适合作为 GitHub 和文件夹的上限。2 MiB 单文件和 128 个文件
|
||||
没有更细的书面理由。
|
||||
|
||||
另一条技能下载路径已经允许大得多的获取。它不是这个安装器。这条路径仍把
|
||||
每个文件放进 buffer 时,不能照搬对方的解压预算。见「修改前的基线」。
|
||||
|
||||
## 修改前的基线
|
||||
|
||||
`packages/cli/src/serve/workspace-skill-management.ts` 对所有来源使用同一
|
||||
套预算:
|
||||
|
||||
| 常量 | 取值 | 约束对象 |
|
||||
| ----------------- | ----- | ------------------------------------------------------------------- |
|
||||
| `MAX_FILES` | 128 | ZIP 条目、GitHub 文件、文件夹文件,以及规范化之后的包 |
|
||||
| `MAX_FILE_BYTES` | 2 MiB | 单个 ZIP 条目、单次 GitHub 响应、单个文件夹文件,以及每个规范化文件 |
|
||||
| `MAX_TOTAL_BYTES` | 6 MiB | 解码后的 base64、ZIP 未压缩字节、GitHub 合计、文件夹合计 |
|
||||
| `MAX_PATH_LENGTH` | 512 | 相对路径和 GitHub 目录 |
|
||||
| `MAX_PATH_DEPTH` | 16 | 相对路径、ZIP 布局、GitHub 遍历和文件夹遍历 |
|
||||
|
||||
`decodeBase64` 拒绝解码后大于 `MAX_TOTAL_BYTES` 的压缩包。`filesFromZip`
|
||||
再把每个条目读进内存(`readZipEntry` 拼接 chunk),并再次套用同一单文件
|
||||
和总大小上限。`fromBuffer` 在整个读取期间保留压缩包。
|
||||
`downloadGitHubDirectory` 把每次 `fetchBytes` 的 buffer 推进 `state.files`。
|
||||
git sparse-checkout 回退先把树检出到 `os.tmpdir()`,再由 `filesFromFolder`
|
||||
读回 buffer。`filesFromFolder` 对每个文件 `readFile`,然后
|
||||
`installWorkspaceSkill` 才把 buffer 写入既有暂存目录(技能父目录下的
|
||||
`.skills-<name>.installing-*`)。合法包的堆峰值是整棵未压缩树;ZIP 路径还
|
||||
要加上压缩包 buffer,以及请求结束前最多 10 MiB 的 JSON 正文。
|
||||
|
||||
`packages/web-shell/client/components/skills/SkillInstallDialog.tsx` 把
|
||||
`MAX_SKILL_ZIP_BYTES` 设为 6 MiB,并在上传前拒绝该文件。
|
||||
`packages/web-shell/client/i18n.tsx` 的两份文案是:
|
||||
|
||||
- `skills.install.error.zipTooLarge`:`The ZIP file must not exceed 6 MB.`
|
||||
- 中文目录:`ZIP 文件不能超过 6 MB。`
|
||||
|
||||
对话框把服务端每一次 `skill_package_too_large` 都映射成这句 ZIP 文案,包括
|
||||
未压缩总量超限的 GitHub 或文件夹包,以及超过 2 MiB 的单个文件。
|
||||
|
||||
大小失败使用 `skill_package_too_large`,HTTP 413。文件数失败使用
|
||||
`invalid_skill_package`,HTTP 400(`Skill package has too many files`、
|
||||
`Skill ZIP contains too many files`,空包则是
|
||||
`Skill package has an invalid file count`)。daemon 文案不包含具体数字。
|
||||
|
||||
`workspace-skill-management.test.ts` 锁住了旧上限:
|
||||
`stops reading an oversized GitHub Skill file`(2 MiB 再加 1 字节)、
|
||||
`reports an expanded ZIP entry over the limit as too large`(一个 2 MiB
|
||||
再加 1 字节的条目),以及
|
||||
`reports a ZIP whose total expanded content is over the limit`(四个
|
||||
1,600,000 字节的文件,约 6.4 MiB)。技能管理设计文档把这些称为 ZIP-bomb
|
||||
限制。
|
||||
|
||||
`packages/cli/src/acp-integration/skill-source-download.ts` 是另一项功能。
|
||||
它允许压缩 100 MiB(`MAX_SKILL_DOWNLOAD_BYTES`)、解压 500 MiB
|
||||
(`MAX_SKILL_DECOMPRESSED_BYTES`)、API 文件 2000 个
|
||||
(`MAX_SKILL_API_FILE_COUNT`)、深度 16(`MAX_SKILL_API_DIR_DEPTH`)。
|
||||
膨胀一旦越过上限会被中止,但合法压缩包仍会 `Buffer.concat`,GitHub API
|
||||
遍历仍保留每个文件的 buffer。这 500 MiB 可以留在 daemon 堆上。本安装器
|
||||
不得采用那个解压数字。
|
||||
|
||||
Codex 仅作引用:`openai/codex` 的 `install-skill-from-github.py` 没有压缩、
|
||||
未压缩、单文件或文件数上限。它把仓库 zip 或 git sparse checkout 复制到
|
||||
磁盘,并拒绝路径逃逸、符号链接、非法技能名、已存在的目标,以及缺少
|
||||
`SKILL.md`。`codex-rs/core-plugins` 的插件包允许远程下载 100 MiB、解压
|
||||
512 MiB(PR #36485,2026-08-01,由 50/250 提高)。Share 与 npm 压缩包为
|
||||
50 MiB,npm 解压为 250 MiB,解压时没有单文件或条目数上限。OpenAI 插件
|
||||
提交门户允许 zip 最大 100 MB、单个成员最大 100 MiB、条目最多 5000、未压缩
|
||||
最大 512 MiB、路径深度 20。其 `skill_bundle_too_large` 的具体数字未公布。
|
||||
这些安装器不把解压后的树留在进程堆里。只要本路径仍缓冲每个文件,就不能
|
||||
对齐它们没有上限的技能安装器,也不能对齐 512 MiB 的解压上限。
|
||||
|
||||
## 目标
|
||||
|
||||
- 把 ZIP 传输上限和磁盘上的包上限拆开。
|
||||
- 提高单文件和文件数上限,使带有真实资源和真实参考目录的技能可以通过。
|
||||
- 保留 zip bomb、路径、符号链接和暂存回滚行为。
|
||||
- 避免在安装器堆中保留整个展开后的包,只保留流式缓冲及另有上限的清单读取。
|
||||
- 用两种语言告诉 Web Shell 新的 ZIP 上限,并停止把每一次大小失败都说成
|
||||
6 MB 的 ZIP。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不新增上传协议。展开大小不再被压成与压缩包相同之后,JSON 正文已经能
|
||||
携带有用的源码 ZIP。放不进传输上限的二进制包走现有的 GitHub 或文件夹
|
||||
来源。
|
||||
- 不修改 `express.json({ limit: '10mb' })`,也不提高全局 10 MiB 正文限制。
|
||||
该限制覆盖每一条 JSON 路由。
|
||||
- 不修改 `packages/cli/src/acp-integration/skill-source-download.ts`。
|
||||
共享常量会把本路径拒绝的堆预算复制过来。
|
||||
- 不增加用户设置、环境变量或按工作区覆盖。
|
||||
- 不修改 `MAX_PATH_LENGTH`(512)或 `MAX_PATH_DEPTH`(16)。深度已经与
|
||||
`MAX_SKILL_API_DIR_DEPTH` 相同。
|
||||
- 保留技能名规则、`SKILL.md` 名称匹配、符号链接拒绝、单层根目录剥离、
|
||||
暂存回滚,以及 GitHub 先 API 再 git 的回退行为,同时将实现适配到磁盘
|
||||
暂存。git 超时仍为 clone 60 秒、sparse-checkout 30 秒。
|
||||
- 不改写 `docs/design/web-shell/web-shell-skill-manager-page.md`。该页没有
|
||||
中文版;改一句话就得整篇翻译。本文是大小限制的规格。旧句子描述的是
|
||||
#7018 交付的安装器。
|
||||
- 不为安装增加并发限制,也不把 GitHub 路径改成一次归档下载。
|
||||
|
||||
## 方案
|
||||
|
||||
用两个上限替换 `MAX_TOTAL_BYTES`。提高单文件和文件数上限。把每个通过检查
|
||||
的文件写入安装器已经会创建的暂存目录,再对写下去的字节套用包上限。
|
||||
|
||||
```text
|
||||
MAX_ZIP_ARCHIVE_BYTES = 7_864_071
|
||||
MAX_FILE_BYTES = 32 * 1024 * 1024 // 33_554_432
|
||||
MAX_PACKAGE_BYTES = 64 * 1024 * 1024 // 67_108_864
|
||||
MAX_FILES = 2000
|
||||
MAX_PATH_LENGTH = 512 // 不变
|
||||
MAX_PATH_DEPTH = 16 // 不变
|
||||
```
|
||||
|
||||
Web Shell 对话框里的 `MAX_SKILL_ZIP_BYTES` 等于 `MAX_ZIP_ARCHIVE_BYTES`。
|
||||
对话框不能导入 CLI 模块。重复这个字面量。不要新增共享包。
|
||||
|
||||
### 传输上限
|
||||
|
||||
合规请求体是紧凑的 `JSON.stringify` 输出:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "<256 chars>",
|
||||
"scope": "workspace",
|
||||
"source": { "type": "zip", "contentBase64": "<base64>" }
|
||||
}
|
||||
```
|
||||
|
||||
`name` 最长为 `MAX_WORKSPACE_SKILL_NAME_LENGTH`(256),字符集为
|
||||
`[A-Za-z0-9._-]`,因此不会被 JSON 转义。`scope` 取更长的 `"workspace"`。
|
||||
`contentBase64` 使用标准字母表,也不会被 JSON 转义。键顺序不改变长度。
|
||||
`contentBase64` 为空时,这段信封是 330 字节。
|
||||
|
||||
```text
|
||||
JSON_BODY_LIMIT_BYTES = 10 * 1024 * 1024 // 10_485_760
|
||||
ENVELOPE_BYTES = 330
|
||||
BASE64_BUDGET = 10_485_760 - 330 // 10_485_430
|
||||
MAX_ZIP_ARCHIVE_BYTES = 3 * floor(10_485_430 / 4) // 7_864_071
|
||||
```
|
||||
|
||||
编码后的正文是 330 + 10,485,428 = 10,485,758 字节,比解析器少 2 字节。
|
||||
再多 1 个原始字节会多出 4 个 base64 字符(10,485,762 字节),在
|
||||
`decodeBase64` 之前就被解析器拒绝。`decodeBase64` 仍对解码后的 buffer
|
||||
执行 `MAX_ZIP_ARCHIVE_BYTES`,避免较短的名字把更大的压缩包送进安装器。
|
||||
客户端在构造 base64 之前用同一个整数检查 `file.size`。
|
||||
|
||||
这大约是 7.50 MiB,只比 6 MiB 略高。ZIP 真正有用的变化不是这点提高。
|
||||
小于 7,864,071 字节的文本型压缩包可以在磁盘上展开到 `MAX_PACKAGE_BYTES`。
|
||||
不可压缩的压缩包做不到,那种包应使用 GitHub 或文件夹来源。
|
||||
|
||||
### 磁盘上的包上限
|
||||
|
||||
读取来源之前先创建现有的暂存目录。对每个接受的文件先按不变的规则校验
|
||||
相对路径,再写入并丢掉正文:
|
||||
|
||||
- **ZIP。** 解码后的压缩包仍用 `fromBuffer`。该 buffer 最大为
|
||||
`MAX_ZIP_ARCHIVE_BYTES`。把每个条目 pipe 到暂存目录中的文件。累计字节。
|
||||
当 `entry.uncompressedSize` 或已读字节将超过 `MAX_FILE_BYTES` 或
|
||||
`MAX_PACKAGE_BYTES` 时,`destroy` 该条目流。不要对条目做 `Buffer.concat`。
|
||||
- **文件夹。** 像现在一样 `lstat`,拒绝符号链接和特殊文件,然后 `copyFile`
|
||||
到暂存目录。不要对载荷 `readFile`。如果复制前后大小变化,则拒绝,与今天
|
||||
的长度检查一致。
|
||||
- **GitHub API。** 把 `fetchBytes` pipe 到暂存文件。`content-length` 和
|
||||
流式总量都对照 `MAX_FILE_BYTES`,累计总量对照 `MAX_PACKAGE_BYTES`。
|
||||
越过上限时取消响应体。
|
||||
- **Git sparse checkout。** 保留临时检出。用文件夹遍历把它复制进暂存目录。
|
||||
在现有的 `finally` 里删除临时检出。不要把检出读回 buffer 数组。
|
||||
|
||||
在消耗 `MAX_FILES` 或 `MAX_PACKAGE_BYTES` 之前跳过 `__MACOSX`、`.DS_Store`
|
||||
和 `._*` 条目,并且不安装它们。今天 ZIP 的预检查会把这些垃圾计入上限,
|
||||
之后 `normalizePackageFiles` 再丢掉。改完之后,上限描述的是实际安装的文件。
|
||||
|
||||
树写到磁盘之后,如果没有根级 `SKILL.md` 且所有文件共享同一个根,用 rename
|
||||
剥掉这一层包裹目录。要求存在根级 `SKILL.md`。像现在一样从暂存文件解析
|
||||
`SKILL.md`。任何失败都删除暂存目录并恢复备份,与现在相同。写入前后继续
|
||||
调用 `assertGenerationOpen`,避免已关闭的 generation 留下技能目录。
|
||||
|
||||
暂存阶段保留的文件正文不随 `MAX_PACKAGE_BYTES` 增长
|
||||
(之后的清单读取成本见「风险」):
|
||||
|
||||
| 来源 | 留在堆上的内容 |
|
||||
| ---------- | ------------------------------------------------------------------------------------------- |
|
||||
| ZIP | 处理函数返回前的 JSON 正文(≤ 10 MiB)、压缩包 buffer(≤ 7,864,071 字节),以及一个流 chunk |
|
||||
| GitHub API | 目录列表加上一个流 chunk |
|
||||
| Git 检出 | 文件正文不留在堆上。Git 和 `copyFile` 走内核 |
|
||||
| 文件夹 | 文件正文不留在堆上。`copyFile` 走内核 |
|
||||
|
||||
重复路径检测仍需要一个路径集合。它保存的是字符串,不是文件内容。
|
||||
|
||||
### 单文件与文件数上限
|
||||
|
||||
`MAX_FILE_BYTES` 为 32 MiB。`MAX_FILES` 为 2000。二者都是磁盘限制,在流式
|
||||
写入之前或期间执行。它们不是堆预留。
|
||||
|
||||
## 决策
|
||||
|
||||
**`MAX_ZIP_ARCHIVE_BYTES = 7_864_071`。** 这是在最坏合规信封下,仍能放进
|
||||
10 MiB JSON 正文的最大原始 ZIP,计算见上。向下取整到 7 MiB 会浪费还能用的
|
||||
字节。向上取整到 7.5 MiB(7,864,320)会让最长技能名在解析器里失败,并看到
|
||||
笼统的 10 MB 错误。客户端与服务端使用同一个整数,文件选择器在上传前失败。
|
||||
|
||||
**`MAX_PACKAGE_BYTES = 64 MiB`。** 6 MiB 是旧的堆预算,却也加在已经位于
|
||||
磁盘上的来源。文件流进暂存目录之后,这个上限是磁盘。64 MiB 够放下
|
||||
`SKILL.md`、脚本,以及不止一个数 MiB 的资源(文档、wasm 模块、小型数据库、
|
||||
图片)。它与 daemon 50 MiB 二进制上传(`packages/cli/src/serve/fs/policy.ts`
|
||||
的 `MAX_UPLOAD_BYTES`)处于同一量级,但不借用那个常量:上传路由会缓冲正文,
|
||||
本路径不能。另一条技能下载器和 Codex 插件解压所用的 100 MiB / 500 MiB /
|
||||
512 MiB 是插件规模的树;在 Qwen 的另一条路径上,500 MiB 仍然是堆预算。
|
||||
它们不是技能暂存预算。
|
||||
|
||||
**`MAX_FILE_BYTES = 32 MiB`。** 2 MiB 会在包的其余部分很小时拒绝一个普通
|
||||
资源。32 MiB 允许一个 PDF、wasm 模块、SQLite 文件、字体或小型二进制辅助
|
||||
程序。它是包上限的一半,第二个大资源必须放进剩余空间。写入使用流或
|
||||
`copyFile`。进程不得为该文件分配 32 MiB 的 `Buffer`。
|
||||
`skill-source-download.ts` 没有单文件上限可供对齐;若改成「无上限」,单个
|
||||
条目会在没有单独提前拒绝的情况下向包上限增长,而 2 MiB 这个常量需要一个
|
||||
仍然能挡住单条目 zip bomb 的替代值。
|
||||
|
||||
**`MAX_FILES = 2000`。** 参考目录加上脚本会在总字节仍然很小时超过 128 个
|
||||
文件。2000 就是 `MAX_SKILL_API_FILE_COUNT`,即仓库内对同类 GitHub 目录
|
||||
遍历的上限。这里它是安全的,因为安装器保留的是路径,不是 2000 个 buffer。
|
||||
不采用门户的 5000,也不采用 Codex 不限文件数的复制。空包仍然失败于
|
||||
`invalid_skill_package`。元数据文件不计数。
|
||||
|
||||
**流进现有暂存目录。** 若只提高 `MAX_TOTAL_BYTES` 并继续填满
|
||||
`SkillPackageFile[]`,新的包上限就会钉在 daemon 堆上。64 MiB 已经占掉一个
|
||||
小 worker 的大部分;若用另一下载器的 500 MiB,就是本设计拒绝的失败方式。
|
||||
暂存目录、回滚,以及从磁盘解析 `SKILL.md`,都已经存在。实现改为更早写入
|
||||
那里,并去掉内存中的整包数组。不新增另一种临时目录,也不新增路由。
|
||||
|
||||
**重试与清理隔离。** Contents API 失败后回退到 git 之前,就地清空暂存
|
||||
目录,同时重置路径集合、文件数与字节数。暂存与备份目录名内嵌持有进程
|
||||
的 pid,因此第二个守护进程会回收崩溃运行的残留,但不会删除活跃对等进程
|
||||
的目录;进程内登记表覆盖从暂存目录创建之前到提交之后的窗口。清理安装
|
||||
产物时匹配技能专属前缀后的完整后缀,安装 `foo` 不会误删
|
||||
`foo.backup-1` 或 `foo.installing-abc123` 的暂存和备份。只有清扫真正
|
||||
可能触及的请求名才以 `invalid_skill_name` 拒绝:以点开头且以
|
||||
`.backup-` 或 `.installing-` 加 `[A-Za-z0-9-]` 结尾的名字,或者以技能
|
||||
加载器本就跳过的双数字 `backup`/`installing` 后缀结尾的名字。
|
||||
`release.installing-notes`、`db.backup-2024` 这类不带前导点的名字仍可
|
||||
安装。本次不引入跨进程安装锁;路由互斥锁按解析后的技能目录加键,使
|
||||
别名 scope 串行。
|
||||
|
||||
**保留一个大小错误码。** `skill_package_too_large`(413)仍覆盖压缩包、
|
||||
单个文件和包总量。文案保持区分。文件数使用单独的错误码,以便 Web Shell
|
||||
翻译。见「实现范围」。
|
||||
|
||||
## 约束
|
||||
|
||||
- 解析器上限是 10,485,760 字节,含等号。`body-parser` 只拒绝超过 `limit`
|
||||
的正文。
|
||||
- 传输公式假定紧凑 JSON 和合法技能名。美化打印、附加字段,或发送会被
|
||||
JSON 转义的名字,仍可能收到笼统的解析器 413。官方客户端对
|
||||
`{ name, scope, source }` 做紧凑的 `JSON.stringify`,但提交前不检查
|
||||
名称长度上限。因此过长的名称仍可能先触发解析器,而非路由校验。
|
||||
- `yauzl` 的 `fromBuffer` 需要整个压缩包。这笔开销由
|
||||
`MAX_ZIP_ARCHIVE_BYTES` 封顶,可以接受。本次不要另加把 ZIP 先落盘再读的
|
||||
读取器。
|
||||
- GitHub Contents API 的目录列表在 API 的 1000 条目分页上限处停止。达到该
|
||||
长度的列表会被视为被截断,并改用现有 git 回退重新读取,因此,在 git
|
||||
传输能够完成认证的范围内(公开仓库,或已配置 git 凭据的主机),枚举
|
||||
完整性不依赖 API 遍历。对于只能凭已配置 API token 访问的私有仓库,
|
||||
达到或超过 1000 条目的列表在延期的回退认证落地之前没有可用的传输通道
|
||||
(见下一条 Git 凭据约束)。2000 个文件的 API 遍历仍可能很慢或触发速率
|
||||
限制。新的文件数上限不保证完成时间。SDK 请求和 Web Shell 操作当前都在
|
||||
30 秒后超时;客户端报告超时后,daemon 仍可能完成安装。取消机制与超时
|
||||
调整留作后续工作。
|
||||
- Git 回退沿用现有 Git 环境和凭证助手,不向 Git 子进程注入配置的 API token。
|
||||
私有仓库回退认证留待后续处理,需要限定凭证主机、保留继承配置,并覆盖
|
||||
延迟拉取文件内容的路径。这一延期是上述 ≥1000 条目列表路径的正确性边界,
|
||||
而不只是增强项。
|
||||
- Web Shell 与 CLI 没有可供这个常量共用的模块。两个字面量必须在评审时保持
|
||||
相等。
|
||||
- 不单独限制 ZIP 压缩比。压缩包和展开字节上限分别生效,高压缩比的包
|
||||
也适用这两个独立上限。
|
||||
|
||||
## 风险
|
||||
|
||||
**如果跳过流式写入,堆会涨。** 只改新常量、仍保留 `SkillPackageFile.content`
|
||||
数组,会使每次进行中的安装持有最多 64 MiB 文件正文,再加上最多 7,864,071
|
||||
字节的 ZIP,再加上 10 MiB JSON 正文。并发安装会倍增。本设计不掩盖这笔
|
||||
成本。如果在新上限下整包仍然留在这些 buffer 里,实现就是错的。
|
||||
|
||||
**ZIP 路径上仍然存在的堆。** ZIP 安装在处理函数结束前仍持有 JSON 文本和
|
||||
解码后的压缩包。二者合计最多是 10 MiB 的 JSON 正文加上 7,864,071 字节的
|
||||
压缩包,再加一个流 chunk。清单校验还会把整个 `SKILL.md`(最多 32 MiB)
|
||||
读成字符串,后续技能加载也会整份读取。流式写入消除了整包正文的保留,
|
||||
并未消除清单的内存成本。不要把任何来源描述成不分配内存。
|
||||
|
||||
**磁盘。** 暂存目录最多可放 64 MiB,rename 之后这就是已安装的技能。替换
|
||||
已有技能时,旧树的备份会短时间同时存在。git 回退在 `finally` 删除检出之前,
|
||||
会短时间同时保留临时检出和暂存副本。检出本身不受安装器上限约束:git
|
||||
先将文件落盘,随后复制时才检查大小。达到 1000 条目分页上限的列表会让
|
||||
最大的那些目录确定性地走这条路径,因此不受限的检出成本不再只出现在
|
||||
429/5xx 重试时。128 MiB 仍然不是磁盘占用上限。
|
||||
失败的安装仍必须
|
||||
删除暂存目录。越过上限的半截条目只能留在该暂存目录内。
|
||||
|
||||
**被拒绝的安装可能留下基础目录。** 技能基础目录在获取来源之前创建,因此
|
||||
来源文件夹不存在、GitHub 404,或 ZIP 解码失败时,失败发生在
|
||||
`<workspace>/.qwen/skills`(或全局对应位置)已经存在之后。只有非法 base64
|
||||
的 ZIP 形状会在创建目录之前被拒绝。留下空目录是有意为之:暂存目录必须与
|
||||
目标同盘,最后的 rename 才能不跨文件系统。
|
||||
|
||||
**如果错误映射不改,界面会误导。** 今天的 ZIP 句子对 GitHub、文件夹和单文件
|
||||
失败已经是错的。只把它改成 7,864,071 字节、却不单独写包大小的句子,这个
|
||||
错误还会留下。
|
||||
|
||||
**Generation 关闭。** `does not create Skill directories after the generation
|
||||
closes` 在第三次 `assertGenerationOpen` 时抛出,并期望不存在 `.qwen` 目录。
|
||||
调整写入顺序后,不能留下 `.qwen` 或暂存目录。调用序列变化时更新检查序号;
|
||||
保持这个可观察结果。
|
||||
|
||||
## 实现范围
|
||||
|
||||
只改这些文件:
|
||||
|
||||
- `packages/cli/src/serve/workspace-skill-management.ts`
|
||||
- `packages/cli/src/serve/workspace-skill-management.test.ts`
|
||||
- `packages/cli/src/serve/workspace-skill-management-recovery.test.ts`
|
||||
(新增;Contents API 失败后的重试与暂存重置,以及安装产物前缀碰撞的隔离)
|
||||
- `packages/web-shell/client/components/skills/SkillInstallDialog.tsx`
|
||||
- `packages/web-shell/client/components/skills/SkillInstallDialog.test.tsx`
|
||||
(新增大小与文件数错误码的用例行)
|
||||
- `packages/web-shell/client/i18n.tsx`(英文和中文两份目录)
|
||||
|
||||
不要改 `error-handlers.ts`、`workspace-skills.ts`(路由只原样传递
|
||||
`contentBase64`)、`skill-source-download.ts`、SDK 类型,或全局 JSON 限制。
|
||||
|
||||
### 行为
|
||||
|
||||
- 解码后的 ZIP 大于 7,864,071 字节:`413 skill_package_too_large`,文案
|
||||
`Skill ZIP exceeds the allowed archive size`。
|
||||
- 单个文件或 ZIP 条目超过 32 MiB:`413 skill_package_too_large`。保留现有
|
||||
句子(`Skill file is too large`、`Skill file is too large: <path>`、
|
||||
`Skill ZIP entry is too large: <name>`、
|
||||
`Skill ZIP entry exceeds the allowed size`)。
|
||||
- 已安装字节超过 64 MiB:`413 skill_package_too_large`。保留
|
||||
`Skill package exceeds the allowed size` 和
|
||||
`Skill ZIP expands beyond the allowed size`。
|
||||
- 已安装文件超过 2000 个:`400 skill_too_many_files`。文案:
|
||||
`Skill package has too many files (max 2000)` 和
|
||||
`Skill ZIP contains too many files (max 2000)`。
|
||||
- 写入暂存目录时抛出写入故障(errno 为 `ENOSPC`、`EDQUOT`、`EROFS`、
|
||||
`EIO`、`EMFILE`、`ENFILE`、`EBUSY`、`EACCES`、`EPERM`):
|
||||
`500 skill_staging_failed`。文件夹复制需区分歧义 errno:Node 对每次
|
||||
`copyFile` 失败都会同时报告来源 `path` 与目标 `dest`,因此 `EACCES`
|
||||
和 `EPERM` 只在来源仍可读时映射为 500;`EIO` 等读写皆可能的 errno
|
||||
保持按来源映射为 400/502。直接的暂存写入(`open`、`write`、`mkdir`)
|
||||
无条件把 `EACCES` 和 `EPERM` 映射为 500。暂存文件在 finalize 前消失
|
||||
同样为 `500 skill_staging_failed`。
|
||||
- 空包、非法路径、重复路径和过深目录:`invalid_skill_package` 的文案和
|
||||
HTTP 400 不变。
|
||||
- ZIP 条目无论先后顺序,只要同一条路径既作为文件又作为目录出现:
|
||||
`400 invalid_skill_package`,文案为
|
||||
`Skill package has conflicting file and directory entries: <路径>`,
|
||||
其中 `<路径>` 是后出现的条目。
|
||||
- 展开后超过 6 MiB、但仍在新上限内的文件夹或 ZIP 可以安装。2 MiB 再加
|
||||
1 字节的文件可以安装。
|
||||
- 路径长度、深度、符号链接、加密、绝对路径和 `..` 的拒绝行为不变。
|
||||
- Web Shell 文件选择器:`file.size > 7_864_071` 时显示
|
||||
`skills.install.error.zipTooLarge`,并且不上传。
|
||||
- Web Shell 收到服务端 `skill_package_too_large`:显示
|
||||
`skills.install.error.packageTooLarge`,不显示 ZIP 句子。
|
||||
- Web Shell 收到服务端 `skill_too_many_files`:显示
|
||||
`skills.install.error.tooManyFiles`。
|
||||
- 其他 `invalid_skill_package` 仍使用
|
||||
`skills.install.error.invalidPackage`。
|
||||
|
||||
### 文案
|
||||
|
||||
`skills.install.error.zipTooLarge`:
|
||||
|
||||
- 英文:`The ZIP file must not exceed 7,864,071 bytes.`
|
||||
- 中文:`ZIP 文件不能超过 7,864,071 字节。`
|
||||
|
||||
`skills.install.error.packageTooLarge`:
|
||||
|
||||
- 英文:`The skill exceeds the installer limit of 32 MiB per file or 64 MiB total.`
|
||||
- 中文:`技能超过安装限制:单文件最大 32 MiB,合计最大 64 MiB。`
|
||||
|
||||
`skills.install.error.tooManyFiles`:
|
||||
|
||||
- 英文:`The skill package has too many files (max 2000).`
|
||||
- 中文:`技能包文件过多(最多 2000 个)。`
|
||||
|
||||
删掉现在这两句带 `6 MB` 的文案。界面不要把 7,864,071 字节说成 7.5 MiB:
|
||||
7.5 MiB 是 7,864,320,会比服务端多接受 249 字节。
|
||||
|
||||
在 `MAX_ZIP_ARCHIVE_BYTES` 旁用简短注释写明信封公式,否则这个数字无法解释。
|
||||
另有两处 why 注释计入预算:一处说明 Contents API 分页上限,一处说明
|
||||
`liveInstallArtifacts` 不变量。除此之外不新增注释。
|
||||
|
||||
## 验证
|
||||
|
||||
更新 `packages/cli/src/serve/workspace-skill-management.test.ts`,以及新增的
|
||||
`packages/cli/src/serve/workspace-skill-management-recovery.test.ts`。设计阶段
|
||||
不跑完整测试套件;实现 PR 跑这两个文件。
|
||||
|
||||
- 断言 `MAX_ZIP_ARCHIVE_BYTES` 等于上面的公式,并且最坏信封加上这么多字节的
|
||||
base64 后 ≤ 10,485,760,再多 1 个原始字节则超出。为该断言导出常量,或者
|
||||
在它已经导出时从模块断言。不要为了配置而导出。
|
||||
- 反转 `reports an expanded ZIP entry over the limit as too large`:2 MiB
|
||||
再加 1 字节的条目应当安装成功。
|
||||
- 反转 `reports a ZIP whose total expanded content is over the limit`:
|
||||
四个 1,600,000 字节的文件应当安装成功。
|
||||
- 替换 `stops reading an oversized GitHub Skill file`:没有 `content-length`、
|
||||
正文为 2 MiB 再加 1 字节时,低于新的单文件上限。`content-length` 为
|
||||
`MAX_FILE_BYTES + 1` 的响应返回 413,且不需要 32 MiB 的正文。一段流式正文
|
||||
达到 `MAX_FILE_BYTES + 1` 字节时仍返回 413 并取消读取。这一次测试可以分配
|
||||
32 MiB 再加 1 字节。不要再分配 64 MiB。
|
||||
- 文件夹:`fs.truncate` 造出的 `MAX_FILE_BYTES + 1` 稀疏文件返回 413。大小
|
||||
之和为 `MAX_PACKAGE_BYTES + 1` 的稀疏文件返回 413。这两个测试不应在测试
|
||||
进程里分配对应的 buffer。
|
||||
- 文件夹或 ZIP:129 个小文件可以安装。2001 个小文件返回
|
||||
`skill_too_many_files`。
|
||||
- 文件夹中 2 MiB 再加 1 字节的文件可以安装。
|
||||
- 现有的符号链接、路径穿越、清单和回滚测试保持通过。
|
||||
- `does not create Skill directories after the generation closes` 仍然不留下
|
||||
技能目录。需要时调整它的检查序号。
|
||||
|
||||
现有 Web Shell 测试中没有断言 6 MB 这句文案的用例
|
||||
(`SkillsManagerPage.test.tsx` 没有)。界面改动包括两份 i18n 目录、对话框
|
||||
常量,以及 `SkillInstallDialog.test.tsx` 中新增的错误码用例。不必为此新增
|
||||
浏览器套件。
|
||||
|
||||
实现 PR 运行聚焦的安装器测试、针对对话框和 i18n 改动的 Web Shell
|
||||
typecheck,以及对所改包的 `npm run typecheck`。不需要超出这些测试的新 E2E
|
||||
计划:行为仍是同一条安装流程,只是上限变了。把这一点记在 PR 的测试说明里。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `workspace-skill-management.ts` 使用的就是上面四个常量。
|
||||
`MAX_TOTAL_BYTES` 消失。`MAX_PATH_LENGTH` 和 `MAX_PATH_DEPTH` 不变。
|
||||
2. `MAX_SKILL_ZIP_BYTES` 为 7,864,071。两份 i18n 目录使用本文的字符串。
|
||||
两份目录都不再对这个错误保留 `6 MB`。
|
||||
3. 评审人可以用 10 MiB 减去 330 字节信封复算 7,864,071。全局 JSON 限制仍是
|
||||
`10mb`。
|
||||
4. GitHub 和文件夹安装写入现有暂存目录,且不保留装满文件正文的
|
||||
`SkillPackageFile[]`。ZIP 展开把条目流 pipe 到文件。压缩包 buffer 仍然
|
||||
存在,并由 `MAX_ZIP_ARCHIVE_BYTES` 限制。
|
||||
5. 超过 6 MiB、但处于 64 MiB、单文件 32 MiB、2000 个文件以内的包,可以从
|
||||
文件夹和 ZIP 安装。越过其中任一上限时,返回本文的状态码和错误码,并且
|
||||
不留下暂存目录或部分技能。
|
||||
6. 符号链接、路径逃逸、深度、路径长度、清单检查和回滚与现在一致。
|
||||
7. `skill-source-download.ts` 和 `error-handlers.ts` 没有 diff。
|
||||
8. 旧的技能管理设计文档不动。它若仍写所有大小都低于 JSON 解析器,安装器
|
||||
限制以本文为准。
|
||||
|
||||
## 待定问题
|
||||
|
||||
没有。上限,以及流进现有暂存目录这一决定,都已确定。如果新的文件数上限使
|
||||
Contents API 遍历太慢,后续可以再把 GitHub 安装改成一次归档下载。那不是
|
||||
本次变更的前提。
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-22-mysql-ci-optimization.md) | [简体中文](2026-10-22-mysql-ci-optimization.zh-CN.md)
|
||||
|
||||
Current database integration CI runs on MySQL 8.4 only. The two-engine descriptions and measurements below record the original optimization; the retained MySQL job still uses one independent instance per fork. See the [current database verification scope](managed-runtime-broker-jdbc.md#database-verification-scope).
|
||||
|
||||
## Problem and evidence
|
||||
|
||||
The main nightly [run 39664846210](https://github.com/QwenLM/qwen-code/actions/runs/39664846210) spent 33 minutes 41 seconds executing 1,060 integration tests in 52 classes. Their reported class times sum to 33 minutes 39 seconds, so execution was effectively serial. This excludes unit tests, Maven cache work, and the runner's Java lock wait.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](2026-10-22-mysql-ci-optimization.md) | [简体中文](2026-10-22-mysql-ci-optimization.zh-CN.md)
|
||||
|
||||
当前数据库集成 CI 仅运行 MySQL 8.4。下文双引擎描述和测量保留原优化时的记录;保留的 MySQL 任务仍为每个 fork 使用独立实例。参见[当前数据库验证范围](managed-runtime-broker-jdbc.zh-CN.md#数据库验证范围)。
|
||||
|
||||
## 问题与证据
|
||||
|
||||
main 夜间[运行 39664846210](https://github.com/QwenLM/qwen-code/actions/runs/39664846210) 执行 52 个类、1,060 个集成用例用了 33 分 41 秒。各类报告耗时累计为 33 分 39 秒,说明实际基本串行。这个时间不包含单元测试、Maven 缓存操作和 runner 的 Java 锁等待。
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Weighted PR Owner Assignment
|
||||
|
||||
[English](2026-10-22-weighted-pr-assignment.md) | [简体中文](2026-10-22-weighted-pr-assignment.zh-CN.md)
|
||||
|
||||
## Problem
|
||||
|
||||
PR assignment currently selects the eligible area owner with the fewest open
|
||||
assigned issues. The load query counts issues, so assigning another PR does not
|
||||
increase that number. One person with 17 issues can receive every new PR routed
|
||||
to a pool whose other members have 18 or more issues.
|
||||
|
||||
## Design
|
||||
|
||||
For PR assignment, give each eligible owner weight `1 / (openIssues + 1)` and
|
||||
sample once from the normalized weights using `Math.random()`. Adding one keeps
|
||||
zero-issue owners valid. Every eligible owner has a positive probability, and
|
||||
a lower measured issue count gives a higher probability within the same pool.
|
||||
|
||||
For example, issue counts of 0, 1, and 3 produce weights of 1, 1/2, and 1/4,
|
||||
and probabilities of 4/7, 2/7, and 1/7. Equal loads produce equal probabilities.
|
||||
A pool containing one eligible owner still selects that owner.
|
||||
|
||||
Keep the PR selector in `.github/scripts/assign-pr-owner.mjs`. The existing
|
||||
issue selector remains in `.github/scripts/assign-issue-owner.mjs`, and issue
|
||||
assignment continues using its current minimum-load and tie-rotation rule.
|
||||
Both paths continue sharing the policy loader and issue-count query.
|
||||
|
||||
Path-based area selection, fallback to a broader area, author exclusion, live
|
||||
write-permission checks, and the pre-write coverage/head checks remain in force.
|
||||
Once a mapped owner is assigned or has reviewed, later runs do not draw another
|
||||
owner. A dry run draws a candidate without writing; a later run can draw someone
|
||||
else because dry runs do not reserve a selection.
|
||||
|
||||
Assignment and dry-run summaries record the exact, unrounded draw, the effective
|
||||
issue loads in eligible-owner selection order, and the owners whose load lookup
|
||||
remained unavailable after retry. The same evidence is retained when GitHub
|
||||
silently rejects the selected assignee. The picker receives the draw explicitly;
|
||||
selection and its summary share that one draw. An unavailable lookup still uses
|
||||
zero for weighting, but its owner appears in the unavailable list so it can be
|
||||
distinguished from a measured zero load.
|
||||
|
||||
## Scope and Tradeoffs
|
||||
|
||||
This changes PR selection only. It adds no settings, dependencies, persistent
|
||||
rotation state, or workflow permissions. The existing count remains capped at
|
||||
100 open issues and excludes PRs. Failed load queries retain the existing retry
|
||||
and zero-load fallback, so the weights represent the available estimate rather
|
||||
than a complete measure of workload.
|
||||
|
||||
Random selection can still choose the same owner repeatedly. It spreads the
|
||||
chance of assignment rather than guaranteeing a rotation or equal workloads.
|
||||
The PR number no longer decides the selected owner.
|
||||
|
||||
## Validation and Acceptance Criteria
|
||||
|
||||
- Deterministic draws verify interval boundaries, zero loads, equal loads,
|
||||
single-owner pools, and a positive chance for a busier owner.
|
||||
- A fixed grid of draws verifies the independently calculated 4:2:1 ratio,
|
||||
avoiding a flaky statistical test against live randomness.
|
||||
- Full script tests control randomness only through a test-process preload and
|
||||
verify that both lower- and higher-load owners can reach the actual assignment
|
||||
request. Reverting to minimum-load selection must fail these checks.
|
||||
- The preload rejects a second random draw. Assignment, dry-run, and silently
|
||||
rejected-assignment summaries expose the draw, ordered effective loads, and
|
||||
unavailable owners. Load-query failure is distinguishable from a true zero.
|
||||
- Preload paths are file URLs, so temporary directories containing `#` or `?`
|
||||
work correctly on POSIX systems.
|
||||
- Existing tests continue to cover author exclusion, area fallback, permission
|
||||
filtering, dry runs, load-query failures, stale heads, existing owners, and
|
||||
assignment API failures. The issue-assignment suite must remain unchanged and
|
||||
pass.
|
||||
- Baseline and updated script probes use a stub GitHub CLI; no real PR is
|
||||
reassigned during verification. This workflow runs independently of the Qwen
|
||||
interactive CLI.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None for this change. Counting assigned PRs or refining area ownership can be
|
||||
considered separately if the issue-based probability is insufficient.
|
||||
@@ -0,0 +1,63 @@
|
||||
# PR 负责人的加权随机分配
|
||||
|
||||
[English](2026-10-22-weighted-pr-assignment.md) | [简体中文](2026-10-22-weighted-pr-assignment.zh-CN.md)
|
||||
|
||||
## 问题
|
||||
|
||||
PR 分配目前会选择所属领域中未关闭且已分配的 Issue 数最少的候选人。
|
||||
负载查询只统计 Issue,因此多分配一个 PR 不会增加该计数。如果某人有 17 个
|
||||
Issue,而其他人都有 18 个或更多,所有进入这个候选池的新 PR 都可能分给同一个人。
|
||||
|
||||
## 设计
|
||||
|
||||
对 PR 分配,为每个符合条件的负责人设置 `1 / (openIssues + 1)` 的权重,
|
||||
通过 `Math.random()` 按归一化后的权重抽取一次。加一使零 Issue 的情况也能正常
|
||||
计算。每个候选人的概率均大于零;在同一候选池中,取得的 Issue 计数越低,概率越高。
|
||||
|
||||
例如,Issue 数为 0、1、3 时,权重分别为 1、1/2、1/4,概率分别为
|
||||
4/7、2/7、1/7。负载相同则概率相同。只有一个符合条件的负责人时仍选择该人。
|
||||
|
||||
PR 选择函数放在 `.github/scripts/assign-pr-owner.mjs` 中。现有 Issue 选择函数
|
||||
保留在 `.github/scripts/assign-issue-owner.mjs` 中,Issue 分配继续使用当前的
|
||||
最小负载和并列轮转规则。两条路径继续共用配置加载和 Issue 计数查询。
|
||||
|
||||
按文件路径选择领域、退回更宽泛的领域、排除作者、实时写权限校验,以及写入前的
|
||||
覆盖状态和 head 检查继续生效。已有候选池负责人被分配或参与评审后,后续运行
|
||||
不会再次抽取。Dry run 会抽取候选人但不写入;由于它不预留结果,后续运行可以
|
||||
抽到不同的人。
|
||||
|
||||
分配和 dry-run 摘要记录未经舍入的原始随机数、按有效候选人选择顺序排列的
|
||||
实际参与计算的 Issue 负载,以及重试后仍无法取得负载的负责人。GitHub 静默拒绝
|
||||
所选负责人时也保留相同证据。选择函数显式接收随机数,选择和摘要共用这一次抽样。
|
||||
查询不可用时仍按零负载计算权重,但该负责人会列在不可用列表中,以区别于实际
|
||||
查询得到的零负载。
|
||||
|
||||
## 范围与取舍
|
||||
|
||||
本次只修改 PR 选择规则,不增加设置、依赖、持久化轮转状态或工作流权限。
|
||||
现有负载计数仍以 100 个未关闭 Issue 为上限,不计 PR。查询失败时仍保留现有的
|
||||
重试和按零负载处理的回退,因此权重反映的是可取得的估计,而非完整工作量。
|
||||
|
||||
随机抽取仍可能连续选中同一个人。它分散的是分配机会,不保证严格轮转或工作量
|
||||
均等。PR 编号不再决定被选中的负责人。
|
||||
|
||||
## 验证与验收标准
|
||||
|
||||
- 使用确定的随机数验证区间边界、零负载、相同负载、单人候选池,以及较忙者仍有
|
||||
非零概率被选中。
|
||||
- 使用固定间距的抽样点验证独立计算的 4:2:1 比例,避免针对实际随机源执行不稳定的
|
||||
统计测试。
|
||||
- 完整脚本测试只通过测试进程的 preload 控制随机数,验证较低和较高负载的候选人
|
||||
都能进入实际分配请求。恢复为最小负载选择后,这些检查必须失败。
|
||||
- Preload 拒绝第二次随机抽样。分配、dry-run 和静默拒绝分配的摘要都应展示原始
|
||||
随机数、按顺序排列的有效负载及查询不可用的负责人,并区分查询失败与实际零负载。
|
||||
- Preload 路径使用文件 URL,确保 POSIX 系统上包含 `#` 或 `?` 的临时目录可用。
|
||||
- 现有测试继续覆盖作者排除、领域回退、权限过滤、dry run、负载查询失败、head
|
||||
变化、已有负责人和分配 API 失败。Issue 分配测试应保持不变并通过。
|
||||
- 基线与新脚本通过模拟 GitHub CLI 验证,不在测试中重新分配真实 PR。这个工作流
|
||||
独立于 Qwen 交互式 CLI 运行。
|
||||
|
||||
## 待定事项
|
||||
|
||||
本次没有待定事项。如果基于 Issue 的概率仍不足以满足需要,可以另行考虑统计
|
||||
已分配 PR 或细化领域负责人。
|
||||
Loaded 100 of 2183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user