Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
23 changes: 23 additions & 0 deletions .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,23 @@ jobs:
fi
export QWEN_REVIEW_DEADLINE_RESERVE_SECONDS
set +e
# The agent streams its ENTIRE transcript to stdout, and the runner
# scans every line for workflow commands. A tool result that quotes
# a file containing one is executed as a command: reviewing a PR
# that touches `actions/setup-node`, the agent read that action's
# own main.ts, which legitimately contains
# `core.info(\`##[add-matcher]${...}\`)`. The runner took the rest
# of the JSON line as a matcher path and errored. Observed on run
# 31167034020 (PR #8681): three `Unable to process command`, and
# 1h37m of review work discarded. Any PR whose review quotes a file
# containing `##[...]` or `::...::` breaks the same way — this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The ::...:: half of this claim is not reachable, and stating it here misdescribes the threat model the guard defends against.

Verified against actions/runner@main, src/Runner.Common/ActionCommand.cs:

  • TryParseV2 (::cmd::…) — message.TrimStart() then StartsWith("::"): line start only.
  • TryParse (##[cmd]…) — message.IndexOf("##["): anywhere in the line.

Under --output-format stream-json every transcript line starts with {, and JSON escapes newlines as \n, so quoted file content can never place a ::…:: at a line start. Only the ##[…] form is reachable from a quoted file — which is exactly why the incident fired from inside a JSON line, and exactly why the resume below needs its leading newline. As written, the comment implies the two forms are symmetric; a future reader could reasonably conclude the leading-newline printf is interchangeable with an echo.

Suggest narrowing to the ##[...] form (the ::...:: asymmetry is worth one clause, since it is what makes the resume fragile).

Raised in the round-1 self-review; still on head.

中文说明

这句里的 ::...:: 一半不可达,写在这里会误述该守卫真正防的威胁模型。

对照 actions/runner@main 的 src/Runner.Common/ActionCommand.cs 核实:TryParseV2(::cmd::…)先 TrimStart() 再 StartsWith("::"),仅行首匹配;TryParse(##[cmd]…)用 IndexOf("##["),行内任意位置匹配。

在 --output-format stream-json 下每行会话都以 { 开头,且 JSON 把换行转义成 \n,因此被引用的文件内容永远无法把 ::…:: 放到行首。能从引用文件触发的只有 ##[…] 形式——这正是事故从 JSON 行内部触发的原因,也正是下面 resume 必须以换行开头的原因。按现在的写法,注释暗示两种形式对称,后来的读者可能因此认为那个带前导换行的 printf 与 echo 可以互换。

建议收窄为只讲 ##[...] 形式(::...:: 的不对称性值得单独一句,因为它才是 resume 脆弱的根源)。第一轮自审已提出,head 上仍未修改。

# repository's own workflows included.
# Turn command parsing off around the agent and nothing else. The
# token is random per attempt, so no output the agent produces can
# guess it and re-enable parsing early.
local stop_token
stop_token="qwen-review-stop-$(date +%s%N)-${RANDOM}${RANDOM}"
echo "::stop-commands::${stop_token}"
# GNU timeout times out command children unless --foreground is used.
timeout --kill-after=10s "${attempt_timeout}s" qwen \
--auth-type openai \
Expand All @@ -1142,6 +1159,12 @@ jobs:
--output-format stream-json \
| tee "$LOG_PATH"
local ps=("${PIPESTATUS[@]}")
# Resume BEFORE anything else can exit: errexit is still off here,
# so this line is reached on every agent outcome — timeout, crash
# or success. Leaving it off would silently swallow this job's own
# ::error:: and the fallback comment's diagnostics for the rest of

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Both specifics in this justification are wrong, while the real justification is stronger and sits one screen below.

  • this job's own ::error:: — this step has no ::error::. fail() (line 753) does echo "$message" >&2, echo "failure_reason=…" >> "$GITHUB_OUTPUT" and echo "$message" >> "$GITHUB_STEP_SUMMARY". Nothing there is a stdout workflow command.
  • the fallback comment's diagnostics for the rest of the run — Post fallback comment on failure is a separate step, and _stopProcessCommand cannot cross a step boundary: ActionCommandManager is constructed per handler in Runner.Worker/Handlers/Handler.cs (hostContext.CreateService<IActionCommandManager>(), non-singleton). It also reads failure_reason from $GITHUB_OUTPUT, which is file-based either way. The blast radius is the remainder of this step, not the run.

The one thing a missing resume actually loses is echo "::warning::Transient review failure (${REASON}) — retrying once…" on line 1281 — same step, after the resume, on the retry path this guard exists to survive. The test's own comment already says exactly that ("losing the retry ::warning::"); the workflow comment is the one that drifted.

The code is correct as-is — only the stated reason needs narrowing to the retry ::warning:: and to "the rest of this step".

Raised in the round-1 self-review; still on head.

中文说明

这段理由里的两个具体说法都不成立,而真正的理由更有力、就在下方一屏处。

  • this job's own ::error:: —— 本步骤没有任何 ::error::。fail()(753 行)只做 echo "$message" >&2、echo "failure_reason=…" >> "$GITHUB_OUTPUT" 和 echo "$message" >> "$GITHUB_STEP_SUMMARY",其中没有 stdout 工作流命令。
  • the fallback comment's diagnostics for the rest of the run —— Post fallback comment on failure 是独立步骤,而 _stopProcessCommand 无法跨步骤:ActionCommandManager 在 Runner.Worker/Handlers/Handler.cs 中按 handler(即按步骤)构造(hostContext.CreateService<IActionCommandManager>(),非单例)。该步骤还是从 $GITHUB_OUTPUT 读 failure_reason,本就是基于文件的。影响范围是本步骤的剩余部分,而不是整个 run。

resume 缺失真正会丢掉的,是 1281 行的 echo "::warning::Transient review failure (${REASON}) — retrying once…" —— 同一步骤、位于 resume 之后、正好在这个守卫要保住的重试路径上。测试里的注释已经写对了("losing the retry ::warning::"),是 workflow 注释这边跑偏了。

代码本身没问题,只需把理由收窄到重试的 ::warning:: 和"本步骤的剩余部分"。第一轮自审已提出,head 上仍未修改。

# the run, turning one broken review into a silent one.
echo "::${stop_token}::"
Comment thread
wenshao marked this conversation as resolved.
Outdated
set -e
local qwen_status="${ps[0]}"
local tee_status="${ps[1]}"
Expand Down
69 changes: 69 additions & 0 deletions scripts/tests/qwen-pr-review-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ function runScenario(scenario, { timeoutMinutes = 180 } = {}) {
' success_mentions_api_error) PAD=$(printf "x%.0s" $(seq 1 600)); r success false "This PR detects the [API Error: ...] pattern and routes to retry. quota and rate.?limit keywords cover the common messages. ${PAD} Review complete: COMMENT posted (0 Critical, 1 Suggestion inline)." ;;',
' success_quotes_status_code) PAD=$(printf "x%.0s" $(seq 1 700)); r success false "This PR adds retry for [API Error: 429 quota exceeded] and similar. ${PAD} Verdict: COMMENT, 0 Critical." ;;',
' success_ends_with_bracket) r success false "Review of [API Error: 429 quota exhausted] handling. Checklist: - [x]" ;;',
// A transcript that quotes a file containing a workflow command. The
// real case: reviewing a PR that touches actions/setup-node, the agent
// read that action's main.ts, which contains `##[add-matcher]...`.
' workflow_command) printf \'{"type":"assistant","content":"90- const matchersPath = ...\\n91- core.info(`##[add-matcher]${path.join(matchersPath, \\x27tsc.json\\x27)}`);"}\\n\'; r success false "Review complete: COMMENT posted (0 Critical)." ;;',
' errresult) r error true "connection dropped mid-review" ;;',
' hardexit) exit 3 ;;',
'esac',
Expand Down Expand Up @@ -140,6 +144,9 @@ function runScenario(scenario, { timeoutMinutes = 180 } = {}) {
.map((d) => Number.parseInt(d, 10));
return {
line,
// The whole transcript, so the stop-commands bracket around the agent
// can be checked in the order the runner would see it.
raw: stdout,
attempts: Number(readFileSync(attemptFile, 'utf8').trim()),
durations,
};
Expand All @@ -148,6 +155,68 @@ function runScenario(scenario, { timeoutMinutes = 180 } = {}) {
}
}

describe('qwen pr review workflow-command containment', () => {
// The agent streams its whole transcript to stdout and the runner scans every
// line for workflow commands, so a tool result that quotes a file containing
// one gets EXECUTED. Observed on run 31167034020 (PR #8681): the agent read
// actions/setup-node's main.ts, whose `core.info(\`##[add-matcher]...\`)`
// made the runner take the rest of the JSON line as a matcher path — three
// `Unable to process command` errors and 1h37m of review work discarded.
const bracketOf = (raw) => {
const stop = raw.match(/::stop-commands::(\S+)/);
return {
token: stop?.[1],
stopAt: stop ? raw.indexOf(stop[0]) : -1,
resumeAt: stop ? raw.indexOf(`::${stop[1]}::`) : -1,
};
};

it('brackets the agent transcript so a quoted command is inert', () => {
const r = runScenario('workflow_command');
// The review still succeeds — containment must not change the outcome.
expect(r.line).toContain('OK outcome=success');

const { token, stopAt, resumeAt } = bracketOf(r.raw);
expect(token).toBeTruthy();
// A fixed token could be re-enabled by anything the agent chose to print.
expect(token).not.toBe('stop-commands');
expect(token.length).toBeGreaterThan(16);
Comment thread
wenshao marked this conversation as resolved.

// The dangerous line must land strictly INSIDE the bracket.
const injected = r.raw.indexOf('##[add-matcher]');
expect(injected).toBeGreaterThan(stopAt);
expect(resumeAt).toBeGreaterThan(injected);
});

it('resumes command parsing on every agent outcome', () => {
// Left off, the rest of the job goes silent: its own ::error:: and the
// fallback comment's diagnostics would stop reaching the log — turning one
// broken review into an unexplained one. The failure paths are the ones
// that matter, since they are what still needs to report.
for (const scenario of ['success', 'hardexit', 'timeout_kill']) {
Comment thread
wenshao marked this conversation as resolved.
const { token, resumeAt } = bracketOf(runScenario(scenario).raw);
expect(token, scenario).toBeTruthy();
Comment thread
wenshao marked this conversation as resolved.
expect(resumeAt, scenario).toBeGreaterThan(-1);
Comment thread
wenshao marked this conversation as resolved.
}
});

it('reads the agent exit status before resuming', () => {
// `echo` clobbers PIPESTATUS, so a resume placed before the capture would
// read the echo's status instead of the agent's and report every timeout
// or crash as a clean run. Pinned on the source because the symptom is a
// silent misclassification, not a failure.
const run = runReviewStep();
const capture = run.indexOf('local ps=("${PIPESTATUS[@]}")');
const resume = run.indexOf('echo "::${stop_token}::"');
Comment thread
wenshao marked this conversation as resolved.
Outdated
expect(capture).toBeGreaterThan(-1);
expect(resume).toBeGreaterThan(capture);
// And the stop must come before the agent it is meant to contain.
expect(run.indexOf('echo "::stop-commands::${stop_token}"')).toBeLessThan(
Comment thread
wenshao marked this conversation as resolved.
Outdated
run.indexOf('--output-format stream-json'),
);
Comment thread
wenshao marked this conversation as resolved.
Outdated
});
});

describe('qwen pr review transient retry', () => {
it('does not retry a clean success', () => {
const r = runScenario('success');
Expand Down
Loading