Skip to content

Commit da5dd5b

Browse files
authored
fix(aicr-cross-review skill): consumer-less demotion; resumable Codex lane (NVIDIA#2102)
Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent 6cb3ab9 commit da5dd5b

2 files changed

Lines changed: 175 additions & 73 deletions

File tree

.agents/skills/aicr-cross-review/SKILL.md

Lines changed: 101 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ git -C "<repo-path>" fetch "https://github.com/NVIDIA/aicr.git" \
119119
# otherwise abort before the names are ever printed, leaving them unreclaimable.
120120
if [ "$(git -C "<repo-path>" rev-parse "$PRREF")" != "<HEAD_SHA>" ]; then
121121
git -C "<repo-path>" update-ref -d "$PRREF"; git -C "<repo-path>" update-ref -d "$BASEREF"
122-
rm -f "$DIFFPATH"; echo "HEAD moved since setup — restart the review"; exit 1
122+
find "$DIFFPATH" -maxdepth 0 -delete; echo "HEAD moved since setup — restart the review"; exit 1
123123
fi
124124
# Echo the names FIRST: under `set -e` an empty or failing diff aborts, and any
125125
# echo below it would never run — leaking the refs and the temp file with a random
@@ -171,6 +171,15 @@ Read only the paths reported `TRUSTED`. `AGENTS.local.md` is normally a symlink
171171
`CLAUDE.local.md`, so it is skipped and the overlay is read through the real file —
172172
no content is lost.
173173

174+
**Verify the workflow script version before Phase 2.** The script about to be passed as
175+
`scriptPath` must contain the sentinel identifier `codexResumeJobId`:
176+
`grep -c codexResumeJobId "<skill-dir>/scripts/workflow.mjs"` — expect a non-zero count.
177+
If it is absent, STOP: the file is a stale or reverted copy, and running it silently
178+
restores the old semantics (observed live: a concurrent session's git operation reverted
179+
uncommitted skill files in a shared checkout, and a full review round ran the old script
180+
unnoticed). The sentinel detects staleness relative to this revision only — if that
181+
identifier is ever renamed, update this check in the same change.
182+
174183
## Phase 1.5: Classify and extract the change list
175184

176185
**Classify** the PR: `code-change` | `adr` | `config-change` | `documentation-only`.
@@ -218,7 +227,7 @@ the consensus mechanics):
218227
- **Review** — Claude Code (reviews the pinned diff directly; it deliberately does
219228
*not* delegate to the `code-review` command, whose step 8 instructs its agent to
220229
`gh pr comment` the result back to the PR), Codex (background dispatch, a 9-min
221-
bounded wait plus up to two continuation waits when the job is still running — about 27 min
230+
bounded wait plus up to four continuation waits when the job is still running — about 45 min
222231
for a live job), CodeRabbit (CLI against a detached worktree at `HEAD_SHA`, explicit
223232
600000 ms timeout — the Bash tool caps any single call at 10 minutes, which is why
224233
Codex exceeds it by waiting across several calls rather than waiting longer), and integration
@@ -288,23 +297,26 @@ the consensus mechanics):
288297

289298
One deliberate exception, at the level of a **finding** rather than a lane. An
290299
integration finding claims a specific consumer breaks, so one lacking
291-
`consumerPath`/`consumerLine` cannot be verified and never enters consensus — but it is
292-
dropped on its own, with a `log()` naming what went, rather than failing the run. The
293-
earlier all-or-nothing rule was disproportionate: on a real run the lane returned
294-
several findings, one of them a genuine evidenced defect, plus a stale-comment finding
295-
that legitimately has no consumer, and the review reported `incomplete` with all four
296-
lanes `ok` and no report produced. The run still stops when **every** integration
297-
finding is unusable, which is the case that motivated the check — silently dropping the
300+
`consumerPath`/`consumerLine` cannot be verified *as an integration claim* — but if it
301+
still locates a defect (its own path/line) with evidence, it is a perfectly reviewable
302+
ordinary finding. It is therefore **demoted** — consumer fields stripped, flagged in the
303+
candidate list, excluded from the integration severity escalation — rather than dropped,
304+
with a `log()` naming what was demoted. Dropping was tried twice and cost a whole run
305+
each time: first when the lane returned several findings and one legitimately
306+
consumer-less observation failed the run; then, after per-finding dropping replaced
307+
that, when the lane's ONLY finding was such an observation and the zero-survivor rule
308+
stopped the run with all four lanes `ok` and no report produced (observed on PR 2097).
309+
The run still stops when every integration finding lacks even a locatable defect or
310+
evidence — that is the case the fail-closed rule exists for: silently dropping the
298311
lane's only finding once yielded `consensusReached: true` while a required lane had
299312
contributed nothing.
300313

301-
"Unusable" is measured on what survives `intake()`, not on the coordinate check alone.
302-
`intake()` independently drops a finding whose `evidence` is blank, so gating on
303-
coordinates let a coordinate-complete, whitespace-evidence finding pass the filter and
304-
then vanish inside `intake()` — zero candidates, `status: ok`, `consensusReached: true`.
305-
That is the same false-clean, in a narrower form. One rule now covers both drop reasons:
306-
a non-empty integration result that yields no accepted finding stops the run, and the
307-
message says how many went for each reason.
314+
"Contributed nothing" is measured on what survives `intake()`: a demoted finding passes
315+
through the same coordinate and evidence gates as every other candidate, so a
316+
consumer-less, whitespace-evidence finding cannot slip through demotion into a
317+
false-clean. One rule covers the stop: a non-empty integration result that yields no
318+
accepted finding — neither as an integration claim nor as a demoted ordinary finding —
319+
stops the run, and the message says how many went for each reason.
308320

309321
**Coordinates are validated by a single shared rule**, `hasCoords`, applied to a
310322
finding's own `path`/`line` in `intake()` — every lane, not just integration — and to
@@ -377,46 +389,71 @@ the consensus mechanics):
377389
- **Wait elapsed, job still alive** (`.waitTimedOut` true with `.job.status` still
378390
`queued`/`running`) — not the end of the lane. The job is dispatched in the background
379391
and outlives the Bash call waiting on it, so it needs more **time**, not another
380-
attempt — and the protocol now gives it exactly that: **up to two continuation waits** on the
392+
attempt — and the protocol now gives it exactly that: **up to four continuation waits** on the
381393
*same* job id in fresh calls. Those are not retries; nothing is re-dispatched and no
382-
work is duplicated. A third `.waitTimedOut` is then genuinely exhausted budget, and
383-
the lane returns `unavailable` with the job id so the result can be fetched later.
394+
work is duplicated. A fifth `.waitTimedOut` is then genuinely exhausted budget, and
395+
the lane returns `unavailable` with the job id in the structured `jobId` field —
396+
required, not prose — so the result can be fetched or the run resumed later.
384397
Measured on a real run: the lane timed out at the full 540000 ms while the job was
385398
demonstrably mid-work, the job was **still** `running` long after the review was
386399
abandoned, and its result stayed retrievable — so reporting exhausted budget there
387400
discarded a required lane, and the whole review with it, over a job that had merely
388401
not finished.
389-
- **Exhausted budget** — a third `.waitTimedOut`, or no parseable JSON at all because
402+
- **Exhausted budget** — a fifth `.waitTimedOut`, or no parseable JSON at all because
390403
the outer timeout killed the call (a dead broker). No retry, no further wait.
391404

392405
The ceiling is not tunable — the wait runs inside a Bash call, and that tool silently
393406
kills any foreground command at 600000 ms. Exceeding 10 minutes requires polling across
394407
several calls, which is exactly what the continuation wait above does: a live job gets
395-
three waits, roughly twenty-seven minutes, without a longer single call. The inner wait is
408+
five waits, roughly forty-five minutes, without a longer single call. The budget was
409+
three waits until a real ~53-minute job on a +1951/−153 PR outlasted even that — hence
410+
five waits, and a resumable `jobId` on exhaustion instead of lost work. The inner wait is
396411
therefore 540000 ms,
397412
deliberately **below** the outer cap: were the two equal, Bash could kill the command
398413
before it printed its JSON, leaving no `.waitTimedOut` to classify on. That is why an
399414
unclassifiable kill counts as exhausted-budget rather than a fast failure — guessing
400-
wrong there costs another full window for nothing.
415+
wrong there costs another full window for nothing. The lane still reports the job id
416+
it was waiting on (`jobId`), so even that kill stays resumable.
401417
- Codex is required, so a lane that is still unavailable after its retry makes the run
402418
report `incomplete`; re-run rather than interpreting a partial result.
419+
- **`incomplete` with a `codexJobId` means the review is NOT lost.** That field is the
420+
live Codex job that outlasted the wait budget, surfaced top-level (alongside
421+
`reviewerStatus`) precisely so recovery is mechanical, not improvised. Poll the job
422+
with the companion status command until it is terminal (a background 60-second loop is
423+
fine — polling is cheap once the workflow is no longer holding a lane open for it),
424+
then resume: `Workflow({scriptPath, resumeFromRunId: "<wf_...>", args: {...prevArgs,
425+
codexResumeJobId: "<job id>"}})``prevArgs` is the previous run's args object, unchanged. The three completed lanes replay from cache, the
426+
Codex lane fetches the existing job's result without dispatching a second one, and the
427+
run proceeds to cross-review and verification normally. Proven live on PR 2097: a
428+
~53-minute job outlasted the then-three-wait budget, and the resumed run recovered it
429+
with zero re-dispatched work.
430+
- **Execute the CodeRabbit lane's STEP blocks verbatim** — same commands and paths, no
431+
substitutions; in particular never swap the `find … -delete` cleanup for `rm` (an
432+
invented `rm -rf` cleanup once blocked a run for hours on a managed-policy
433+
confirmation prompt).
403434
- CodeRabbit slow runs: check the newest file in `~/.coderabbit/logs/` (429/queue lines
404435
mean cloud-side queueing) and confirm `which -a coderabbit` resolves to the
405436
brew-managed binary — a stale `~/.local/bin` copy shadows it.
406437
- **A sandboxed CodeRabbit run hangs instead of failing.** `~/.coderabbit` is outside the
407-
sandbox write allowlist, so the CLI cannot create its log or review store; it stalls at
408-
`connecting_to_review_service` until the timebox kills it. The lane therefore runs the
409-
`coderabbit` command with sandbox bypass, and only that command.
410-
411-
**Why bypass rather than an allowlist entry.** Adding `~/.coderabbit` to the sandbox
412-
write allowlist is the narrower grant and was considered first. It was rejected because
413-
this skill is checked into the repo and has to work on a contributor's machine as
414-
written: an allowlist entry lives in each person's local settings, so anyone who has not
415-
made the same edit gets the silent ten-minute hang above rather than a usable lane. The
416-
bypass is portable and self-documenting at the call site. Its cost is a permission
417-
prompt on step 2 of every round, which is accepted. Adding the allowlist entry locally
418-
is still worthwhile if you run this often — it does not conflict with the bypass — but
419-
the skill must not depend on it.
438+
default sandbox write allowlist, so the CLI cannot create its log or review store; it
439+
stalls at `connecting_to_review_service` until the timebox kills it. The lane therefore
440+
**probes** in step 1 whether `~/.coderabbit` is sandbox-writable, and runs the
441+
`coderabbit` command (and only that command) with sandbox bypass exactly when the probe
442+
says it is not.
443+
444+
**Why probe-gated bypass rather than an allowlist assumption.** Adding `~/.coderabbit`
445+
to the sandbox write allowlist is the narrower grant, but this skill is checked into
446+
the repo and has to work on a contributor's machine as written: an allowlist entry
447+
lives in each person's local settings, so a lane that *assumed* it would hand anyone
448+
who has not made the edit the silent ten-minute hang above rather than a usable lane.
449+
And the hang means "try sandboxed, fall back on failure" without a probe costs a full
450+
timebox per wrong guess. The step-1 probe settles it in milliseconds: machines with
451+
the allowlist entry run step 2 fully sandboxed and pay **no bypass prompt at all**;
452+
every other machine gets the bypass from the start, portable and self-documenting at
453+
the call site. Add `~/.coderabbit` (and `~/.claude/plugins/data`, for the Codex
454+
companion's job log) to your local sandbox `filesystem.allowWrite` if you run this
455+
often — that is what removes the per-round approval prompts — but the skill must not
456+
depend on it.
420457

421458
**Diagnose it by absence:** a stall at `connecting` *with no new file in
422459
`~/.coderabbit/logs/`* is sandbox denial — a process killed mid-run still flushes a
@@ -553,6 +590,11 @@ Build from the workflow's return value plus the CI status line from Phase 3:
553590
| # | Changed File | Consumer File | Severity | Description | Confirmed By |
554591
|---|--------------|---------------|----------|-------------|--------------|
555592

593+
<only findings with a verified consumer pair (non-null `consumerPath`/`consumerLine`)
594+
belong here. A finding flagged "demoted from integration claim" has no consumer pair —
595+
route it to Confirmed Issues like any ordinary finding, even though its `sources`
596+
include the integration lane; omit the section if empty>
597+
556598
### Unresolved (no settled disposition)
557599

558600
| # | File | Line | Severity | Description | Why unresolved |
@@ -619,23 +661,28 @@ a shell variable, so substitute the literal path here.
619661
- **This skill never executes the reviewed commit's code.** No builds, tests, coverage,
620662
package managers, or repository scripts. If a claim can only be settled by running
621663
something, it is an open question.
622-
- Confirmed integration findings identifying broken consumers escalate to at least
623-
**medium** severity (done in-script).
664+
- Confirmed integration findings identifying broken consumers (a verified
665+
`consumerPath`/`consumerLine` pair) escalate to at least **medium** severity
666+
(done in-script); findings demoted from the integration lane do not.
624667
- Severity scale: critical (must fix) > major (should fix) > medium > minor.
625668
- Keep the report concise — actionable findings, not noise.
626669
- Never set `dangerouslyDisableSandbox` for reviewer or companion commands; they run
627670
fine sandboxed. **Exactly two exceptions**, both kept in sync with the protocols in
628-
`scripts/workflow.mjs`, and both scoped to a single command that performs no Git
629-
operation, no working-copy mutation, and no GitHub write. (They do *read* files —
630-
CodeRabbit necessarily reads the detached worktree it was pointed at. The rule bars
631-
bypassing calls that **act on** the working copy, not calls that read a path):
671+
`scripts/workflow.mjs`, both conditional on the sandbox actually denying the write,
672+
and both scoped to a single command that performs no Git operation, no working-copy
673+
mutation, and no GitHub write. (They do *read* files — CodeRabbit necessarily reads
674+
the detached worktree it was pointed at. The rule bars bypassing calls that **act on**
675+
the working copy, not calls that read a path):
632676
- **Codex companion** — it writes its job log under `~/.claude/plugins/data`, which is
633-
sandbox-denied. If dispatch fails on that write, bypass for that call only.
634-
- **CodeRabbit review**`~/.coderabbit` is outside the write allowlist, so a
677+
sandbox-denied by default. If dispatch fails on that write, bypass for that call
678+
only; a machine whose sandbox allowlist covers the path never needs the bypass.
679+
- **CodeRabbit review**`~/.coderabbit` is outside the default write allowlist, so a
635680
sandboxed CLI cannot create its log or review store and hangs at
636-
`connecting_to_review_service` until the timebox kills it. Bypass **step 2 only** of
637-
the three-step CodeRabbit protocol, which is a lone `coderabbit review` command;
638-
worktree setup and cleanup live in steps 1 and 3 and stay sandboxed.
681+
`connecting_to_review_service` until the timebox kills it. Step 1 of the three-step
682+
CodeRabbit protocol probes whether `~/.coderabbit` is sandbox-writable; when it is
683+
not, bypass **step 2 only**, which is a lone `coderabbit review` command. When the
684+
probe passes, step 2 runs sandboxed and no bypass happens at all. Worktree setup and
685+
cleanup live in steps 1 and 3 and stay sandboxed always.
639686

640687
Anything else stays sandboxed. In particular, never bypass a call that also performs
641688
`git` operations — that is why the CodeRabbit protocol is split into three calls rather
@@ -653,7 +700,10 @@ a shell variable, so substitute the literal path here.
653700
through literally, and `status` is readonly. Keep it in one place — the two blocks that
654701
once held hand-written copies each acquired theirs only after a zsh bug had already
655702
shipped in them, and a new lane must not be able to omit it by accident.
656-
- **Clean up before finishing:** `rm -f <the DIFFPATH echoed in Phase 1>` and delete the
703+
- **Clean up before finishing:** `find "<the DIFFPATH echoed in Phase 1>" -maxdepth 0 -delete
704+
2>/dev/null || true` — idempotent, because under `set -e` a diff file already removed by an
705+
earlier abort path would otherwise fail the call and skip the ref cleanup below —
706+
and delete the
657707
two scoped refs captured in Phase 1 (`git -C "<repo-path>" update-ref -d "$PRREF"`,
658708
same for `"$BASEREF"` — use the exact names echoed there, not a guess). Confirm no
659709
`${TMPDIR:-/tmp}/cr-rabbit.*` worktree path remains in `git worktree list` — write the
@@ -664,3 +714,7 @@ a shell variable, so substitute the literal path here.
664714
leaves one behind. Step 1 of the next run reaps `cr-rabbit.*` directories older than
665715
120 minutes, which bounds the leak but does not clear it now. Do not compare total
666716
worktree counts; concurrent sessions change the total legitimately.
717+
Deletion is `find … -delete` rather than `rm` throughout this skill and its workflow —
718+
managed (admin-deployed) permission policies commonly gate `rm` behind a confirmation
719+
prompt (`Bash(rm:*)` ask rules match every sub-command and override allow rules), and a
720+
background lane blocked on a prompt stalls the review. Do not "simplify" back to `rm`.

0 commit comments

Comments
 (0)