Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a304212
fix(ci): run /resolve without the container sandbox and pin its CLI v…
wenshao Aug 28, 2026
7a11e78
Merge branch 'main' into fix/resolve-restore-no-sandbox
wenshao Aug 28, 2026
03d524c
Merge branch 'main' into fix/resolve-restore-no-sandbox
qwen-code-dev-bot Aug 28, 2026
a2aba41
ci: record the resolve-pr growth in .size-baseline and drop two stale…
wenshao Aug 28, 2026
1896f37
fix(ci): scrub agent-planted runner state before the resolve-pr crede…
qwen-code-dev-bot Aug 29, 2026
f745f9d
fix(ci): invoke the /resolve agent directly with decoyed runner files…
wenshao Aug 29, 2026
f38435c
Merge origin/main into fix/resolve-restore-no-sandbox
wenshao Aug 29, 2026
91678f0
chore: drop an unrelated lint-staged reformat picked up by the merge …
wenshao Aug 29, 2026
5a382fa
Merge remote-tracking branch 'origin/main' into fix/resolve-restore-n…
wenshao Aug 29, 2026
c5a45e5
fix(ci): close the resolve-pr containment bypasses from review round …
wenshao Aug 29, 2026
d7ff487
Merge branch 'main' into fix/resolve-restore-no-sandbox
wenshao Aug 29, 2026
5648cc2
Merge branch 'main' into fix/resolve-restore-no-sandbox
wenshao Aug 29, 2026
af381c1
fix(ci): publish the /resolve resolution from a job that never ran th…
wenshao Aug 29, 2026
1d7b829
test(ci): pin the /resolve agent invocation wiring and ordering
wenshao Aug 29, 2026
4fb0c44
fix(ci): keep /resolve re-runs from republishing a stale resolution
qwen-code-dev-bot Aug 30, 2026
06d174b
fix(ci): let /resolve partial re-runs publish the agent's resolution
qwen-code-dev-bot Aug 30, 2026
d166a23
Merge remote-tracking branch 'origin/main' into fix/resolve-restore-n…
wenshao Aug 30, 2026
25c2935
Merge branch 'main' into fix/resolve-restore-no-sandbox
qwen-code-dev-bot Aug 30, 2026
a851761
fix(ci): bind the follow-up bot's CLI pin on its primary lane (#10428)
qwen-code-dev-bot Aug 30, 2026
15399f4
fix(ci): give the /resolve publisher a per-run group so a completed r…
wenshao Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions .github/scripts/qwen-triage-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,15 @@ describe('qwen-triage: agent tool/permission settings', () => {

// The same settings_json → settings bug survived in two more workflows after
// the triage fix. An unknown action input is dropped without error, so the
// /resolve agent ran every time with no turn cap, no tool allowlist, and no
// sandbox — on a runner pool its runs-on comment chose specifically because
// `sandbox: true` needs docker — and the follow-up bot ran uncapped too.
// These blocks were therefore never validated by anything; parse them here.
// /resolve agent ran every time with no turn cap and no tool allowlist, and
// the follow-up bot ran uncapped too. These blocks were therefore never
// validated by anything; parse them here.
//
// The sandbox key is pinned to FALSE, not true. The fix that made the block
// take effect (#9252) also switched the container on, and /resolve went from
// 84% pushed to 0 of 81 in a row: the versioned ghcr image lags the npm
// release (#9898) so the agent died at startup, or it hung silently to the job
// timeout. No sandboxed /resolve run has ever pushed a resolution.
describe('qwen-code-pr-review.yml resolve-pr: agent settings', () => {
it('passes `settings:` (not the silently-dropped `settings_json:`)', () => {
assertSettingsContract(resolveConflictsStep, 'resolve_conflicts');
Expand Down Expand Up @@ -275,22 +280,37 @@ describe('qwen-code-pr-review.yml resolve-pr: agent settings', () => {
]) {
assert.ok(core.includes(t), `tools.core must include ${t}`);
}
// The runs-on comment pins this job to hosted runners because the sandbox
// needs docker; dropping the key would pay that routing cost for nothing.
// Explicit false, not absent: absent means "whatever the CLI defaults to
// or QWEN_SANDBOX says", and the one time this flipped on it took the
// command down for 13 days (see the describe comment).
assert.equal(
settings.tools?.sandbox,
true,
'tools.sandbox must stay true — the runs-on routing depends on it',
false,
'tools.sandbox must stay false — every sandboxed /resolve run has failed; re-enable only with a dry-run that finishes inside the container',
);
});

it('pins the CLI version instead of following `latest`', () => {
// `latest` ties the job to the npm release pipeline of the moment: on
// 2026-08-15 the dist-tag pointed at an unresolvable 0.21.12 and every
// /resolve died on `npm error notarget` before the agent started.
const version = resolveConflictsStep.with?.qwen_cli_version;
assert.match(
String(version),
/^\d+\.\d+\.\d+$/,
'qwen_cli_version must be an exact semver, not a dist-tag',
);
});

it('keeps resolve-pr on hosted runners (sandbox: true needs docker)', () => {
// The routing half of the sandbox coupling: the ECS pool ships no
// container runtime, so an ECS-routed sandboxed agent dies at startup.
it('keeps resolve-pr on an ephemeral hosted runner', () => {
// This job merges the base branch and force-pushes to the PR head; a fresh
// runner is the cheapest guarantee that nothing from an earlier attempt
// is carried into the credentialed push. (It used to be pinned for docker
// as well; the agent no longer runs sandboxed, so that half is moot.)
assert.equal(
resolvePrJob['runs-on'],
'ubuntu-latest',
'resolve-pr must stay on hosted runners — sandbox: true needs docker, absent on the ECS pool',
'resolve-pr must stay on an ephemeral hosted runner',
);
});
});
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/.size-baseline
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
5942 qwen-autofix-fork-signal.yml
426924 qwen-autofix.yml
7061 qwen-ci-flaky-rerun.yml
165876 qwen-code-pr-review.yml
173427 qwen-code-pr-review.yml
79041 qwen-fleet-shepherd.yml
20525 qwen-issue-followup-bot.yml
5760 qwen-pr-safety-precheck.yml
Expand Down
91 changes: 71 additions & 20 deletions .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2384,7 +2384,8 @@ jobs:
# `true`. The variable is daemon state, and autofix's preflight is what
# checks it up front, failing the job in seconds instead of at agent
# startup. Anything moving a sandboxed job onto the pool should copy that
# preflight — see #9556.
# preflight — see #9556. (Moot for now: the agent step below runs with
# tools.sandbox false, for the reasons recorded on its settings block.)
runs-on: 'ubuntu-latest'
timeout-minutes: 120
# Shared with qwen-autofix.yml's review-address job — see the rationale
Expand Down Expand Up @@ -2594,6 +2595,14 @@ jobs:
if: "steps.prepare.outputs.decision == 'run'"
id: 'resolve_conflicts'
uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7'
# A pushed resolution takes 6 minutes at the median, 41 at p95 and 70
# at p99 (294 runs, request to result comment); only two ever took
# longer than 75. The 120-minute job timeout has therefore never ended
Comment thread
wenshao marked this conversation as resolved.
Resolved
# a healthy run, only hung ones — and a hang billed the full 120
# minutes 40 times in 2026-08. 75 keeps the p99 tail and cuts that
# bill by a third. Keep AGENT_TIMEOUT_MINUTES on 'Report result' in
# step with this value; a test pins the pair.
timeout-minutes: 75
env:
PR_NUMBER: '${{ steps.resolve.outputs.pr_number }}'
BASE_REF: '${{ steps.prepare.outputs.base_ref }}'
Expand All @@ -2602,14 +2611,37 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'
# Pinned, not `latest`. The action installs the CLI from npm at run
# time, so `latest` makes every /resolve depend on the release
# pipeline of the moment: on 2026-08-15 the dist-tag moved to
# 0.21.12 before the tarball was resolvable and 14 runs in a row
# died on `npm error notarget`. 0.21.10 is the last version with a
# measured record on this job (16 of 19 requests pushed on
# 2026-08-12). Bump it on purpose, the way `uses:` above is bumped,
# after a dry-run /resolve on the new version.
qwen_cli_version: '0.21.10'
Comment thread
wenshao marked this conversation as resolved.
Resolved
# The input name is `settings` — this action version has no
# `settings_json` input, and an unknown input is silently dropped.
# That is exactly what happened to this block: every /resolve run so
# far ignored it, and the agent ran without the turn cap, toolset
# allowlist, or the sandbox the runs-on comment above assumes.
# That is exactly what happened to this block until #9252: every
# /resolve run before it ignored the block, and the agent ran without
# the turn cap or toolset allowlist.
# tools.core specifiers (e.g. `run_shell_command(git add)`) are advisory:
# the permission manager keys on the tool name and drops the parenthesised
# command. Real containment = sandbox + write authorization + no agent token.
# command. Real containment = write authorization + no agent token +
# the scope guard in 'Resolution check' + an ephemeral runner.
#
# tools.sandbox is deliberately false. The day #9252 made this block
Comment thread
wenshao marked this conversation as resolved.
Resolved
# take effect, `sandbox: true` took effect with it, and /resolve went
Comment thread
wenshao marked this conversation as resolved.
Resolved
# from 84% pushed (273 agent runs, 2026-06-25..08-14) to 0 of 81
# (2026-08-15..08-27). Inside the container the agent either died at
# startup — the versioned `ghcr.io/qwenlm/qwen-code:<cli>` image lags
# the npm release (#9898: `manifest unknown` on 0.21.13, 0.22.0 and
# 0.22.1; 24 runs) — or produced no output at all until the job
# timeout (40 runs; `git status` afterwards showed the merge had not
# even started). No sandboxed /resolve run has ever pushed a
# resolution; every pushed one ran without the container. Turning the
# sandbox back on needs the same evidence the other way: a dry-run
# with `qwen_debug` that shows the agent finishing inside it.
settings: |-
{
"model": {
Expand All @@ -2634,7 +2666,7 @@ jobs:
"run_shell_command(mkdir)",
"run_shell_command(pwd)"
],
"sandbox": true
"sandbox": false
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
}
}
prompt: |-
Expand Down Expand Up @@ -2690,8 +2722,12 @@ jobs:
# and the merge-tree check below would misreport "still has conflicts".
# Surface the real cause instead.
if [ "$RESOLVE_OUTCOME" != "success" ]; then
echo "The conflict-resolution agent step did not succeed (outcome=${RESOLVE_OUTCOME}): API/quota/infrastructure error or cancellation. Check its logs."
echo "The conflict-resolution agent step did not succeed (outcome=${RESOLVE_OUTCOME}): CLI install, model endpoint or infrastructure error, its step timeout, or cancellation. Check its logs."
echo "outcome=failed" >> "$GITHUB_OUTPUT"
# Distinguish "never reached a verdict" from "reached a wrong one":
# 'Report result' words the comment differently and does not invite
# a re-run, because a re-run repeats an infrastructure failure.
echo "failure_kind=infra" >> "$GITHUB_OUTPUT"
exit 1
fi

Expand Down Expand Up @@ -2755,13 +2791,14 @@ jobs:
# itself changed (conflict resolutions live in those same files).
# Anything edited outside that set is out of scope — a prompt-injection
# symptom — so fail closed and list the offending files. Granularity is
# deliberately file-level, not per-hunk: real containment is sandbox +
# write authorization + no agent token; this is one defense-in-depth layer.
# deliberately file-level, not per-hunk: real containment is write
# authorization + no agent token + an ephemeral runner; this is one
# defense-in-depth layer.
merge_base="$(git merge-base "origin/${BASE_REF}" "$HEAD_FETCH_REF")"
# -z/sort -zu mirrors the conflict-marker check above so unusual
# filenames in the diff are handled consistently. ponytail: tr back to
# newlines because bash vars can't hold NUL — a filename containing a
# literal newline still splits, but that's covered by the sandbox +
# literal newline still splits, but that's covered by the
# write-authorization + no-token containment this guard backstops.
base_changed="$(git diff --name-only -z "${merge_base}" "origin/${BASE_REF}" | sort -zu | tr '\0' '\n')"
agent_changed="$(git diff --name-only -z "$HEAD_FETCH_REF" HEAD | sort -zu | tr '\0' '\n')"
Expand Down Expand Up @@ -2834,6 +2871,10 @@ jobs:
HEAD_SHA: '${{ steps.prepare.outputs.head_sha }}'
HEAD_REPO: '${{ steps.prepare.outputs.head_repo }}'
OUTCOME: '${{ steps.verify.outputs.outcome }}'
FAILURE_KIND: '${{ steps.verify.outputs.failure_kind }}'
RESOLVE_OUTCOME: '${{ steps.resolve_conflicts.outcome }}'
# Mirrors timeout-minutes on 'Resolve conflicts'; a test pins the pair.
AGENT_TIMEOUT_MINUTES: '75'
DRY_RUN: '${{ env.DRY_RUN }}'
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
Expand Down Expand Up @@ -2957,16 +2998,26 @@ jobs:
append_safe_file "${WORKDIR}/no-action.md"
;;
*)
echo "Qwen Code attempted to resolve merge conflicts but the run did not complete successfully."
echo
for file in failure.md address-summary.md no-action.md; do
if [ -s "${WORKDIR}/${file}" ]; then
echo "### ${file}"
append_safe_file "${WORKDIR}/${file}"
echo
fi
done
echo "Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for full logs."
if [ "$FAILURE_KIND" = "infra" ]; then
# The agent never reached a verdict: outcome=failure (CLI
# install, model endpoint, crash) or outcome=cancelled (its
# step timeout). Say which, and do not invite a re-run: the
# generic text below reads as "the model gave up", and 13
# days of re-runs against one broken install (#9898) is what
# this branch exists to stop.
echo "Qwen Code could not run conflict resolution on this PR: the agent step ended with \`outcome=${RESOLVE_OUTCOME}\` before producing a result — a CLI install, model endpoint or infrastructure failure, or the step's ${AGENT_TIMEOUT_MINUTES}-minute timeout. This is not a verdict on the conflict. Requesting /resolve again will fail the same way until the cause shown in the [workflow run](${RUN_URL}) is fixed."
else
echo "Qwen Code attempted to resolve merge conflicts but the run did not complete successfully."
echo
for file in failure.md address-summary.md no-action.md; do
if [ -s "${WORKDIR}/${file}" ]; then
echo "### ${file}"
append_safe_file "${WORKDIR}/${file}"
echo
fi
done
echo "Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for full logs."
fi
;;
esac
} > "${WORKDIR}/report.md"
Expand Down
60 changes: 58 additions & 2 deletions scripts/tests/qwen-resolve-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -787,8 +787,14 @@ describe('qwen resolve workflow', () => {
expect(resolveJob).toContain('persist-credentials: false');
// The resolution check carries no writable GitHub token (defense in depth).
expect(resolveJob).toContain("GITHUB_TOKEN: ''");
// The agent runs sandboxed.
expect(resolveJob).toContain('"sandbox": true');
// The agent runs WITHOUT the container sandbox — measured, not assumed:
// the first day `sandbox: true` took effect (#9252, 2026-08-16) /resolve
// went from 84% pushed to 0 of 81, dying on a missing versioned image or
// hanging to the job timeout. Containment is the no-token agent, the scope
// guard and the ephemeral runner. Flipping this back needs a dry-run that
// shows the agent finishing inside the container.
expect(resolveJob).toContain('"sandbox": false');
expect(resolveJob).not.toContain('"sandbox": true');
// Concurrent /resolve runs must not interleave on the credentialed push.
expect(resolveJob).toContain('cancel-in-progress: false');
});
Expand All @@ -805,6 +811,56 @@ describe('qwen resolve workflow', () => {
expect(agentStep).not.toContain('CI_DEV_BOT_PAT');
});

it('pins the CLI version and bounds the agent step', () => {
const agentStep = resolveJob.slice(
resolveJob.indexOf("- name: 'Resolve conflicts'"),
resolveJob.indexOf("- name: 'Resolution check'"),
);
// `latest` ties every run to the npm release pipeline of the moment: the
// 2026-08-15 dist-tag pointed at an unresolvable 0.21.12 and 14 runs died
// on `npm error notarget` before the agent started.
expect(agentStep).toMatch(/qwen_cli_version: '\d+\.\d+\.\d+'/);
expect(agentStep).not.toContain("qwen_cli_version: 'latest'");
// A hung agent must not bill the whole 120-minute job; the step timeout
// and the number quoted in the failure comment must agree.
const stepTimeout = agentStep.match(/^\s+timeout-minutes: (\d+)$/m);
expect(stepTimeout).not.toBeNull();
const reportStep = resolveJob.slice(
resolveJob.indexOf("- name: 'Report result'"),
);
expect(reportStep).toContain(`AGENT_TIMEOUT_MINUTES: '${stepTimeout[1]}'`);
expect(Number(stepTimeout[1])).toBeLessThan(120);
Comment thread
wenshao marked this conversation as resolved.
Resolved
});

it('reports an agent that never ran as an infrastructure failure, not a verdict', () => {
// outcome != success on the agent step means no resolution was attempted
// (install/model/infra error, step timeout, cancellation). The comment
// must say so and must not invite a re-run, which repeats the failure.
expect(resolveJob).toContain(
'echo "failure_kind=infra" >> "$GITHUB_OUTPUT"',
);
expect(resolveJob).toContain(
"FAILURE_KIND: '${{ steps.verify.outputs.failure_kind }}'",
);
expect(resolveJob).toContain(
"RESOLVE_OUTCOME: '${{ steps.resolve_conflicts.outcome }}'",
);
const infraIdx = resolveJob.indexOf(
'Qwen Code could not run conflict resolution on this PR',
);
expect(infraIdx).toBeGreaterThan(-1);
Comment thread
wenshao marked this conversation as resolved.
Resolved
const infraLine = resolveJob.slice(
infraIdx,
resolveJob.indexOf('\n', infraIdx),
);
expect(infraLine).toContain('This is not a verdict on the conflict');
expect(infraLine).not.toContain('Re-run /resolve');
// The generic wording survives for the cases where the agent did run.
expect(resolveJob).toContain(
'Qwen Code attempted to resolve merge conflicts but the run did not complete successfully.',
);
});

it('supports dry-run and workflow_dispatch', () => {
expect(workflow).toContain('github.event.inputs.dry_run');
expect(workflow).toContain('in dry-run mode');
Expand Down
Loading