Skip to content

fix(review-pr): make review lock release token-independent and TTL crash-safe#49

Open
Sayt-0 wants to merge 1 commit into
mainfrom
fix/review-lock-ttl-release
Open

fix(review-pr): make review lock release token-independent and TTL crash-safe#49
Sayt-0 wants to merge 1 commit into
mainfrom
fix/review-lock-ttl-release

Conversation

@Sayt-0

@Sayt-0 Sayt-0 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Fixes bug B4: the concurrent-review lock was broken in both directions, producing duplicate reviews during long runs and silent skips ("review does not start") after short runs.

Root causes

Direction Cause Effect
Fail-open Lock TTL (600 s) < agent timeout (2700 s) Any request between minute 10 and 45 of a running review saw a "stale" lock and started a duplicate concurrent review
Fail-closed Release used DELETE /actions/caches, which requires actions: write; the review job token had no actions scope, so the delete failed silently Every request within 600 s of a completed review was skipped silently (green run, no message on the PR)

Fix

Release no longer depends on token scopes: a completed run saves a -released marker cache entry under the same key prefix. Cache saves go through the runner's cache-service token, so they work regardless of github.token scopes. The REST DELETE remains as logged, best-effort cleanup. The TTL becomes a crash-only fallback, raised to 3600 s (above the 2700 s agent timeout plus setup/posting overhead, and above the 50 min job cap).

acquire:  save  pr-review-lock-<repo>-<pr>-<run_id>-<run_attempt>            (timestamp)
release:  save  pr-review-lock-<repo>-<pr>-<run_id>-<run_attempt>-released   (timestamp + released file)
check:    restore newest entry matching prefix pr-review-lock-<repo>-<pr>-
            |- has "released" file       -> proceed (previous review finished)
            |- timestamp < 3600 s old    -> skip (review genuinely in progress)
            '- older / missing timestamp -> proceed (holder crashed, TTL fallback)

Hardening details:

  • The acquire step deletes any restored released file, so a run's own lock entry cannot be misread as a release by concurrent runs.
  • Release steps are gated on steps.acquire-lock.outcome == 'success', so a run that never took the lock cannot release another run's lock.
  • Save keys include run_attempt, so re-run attempts can save their own entries (cache keys are immutable).
  • Failed cache deletes are now logged instead of fully silenced.

Changes

File Change
review-pr/action.yml Lock evaluate/acquire/release logic, TTL 600 s to 3600 s, release-marker step, updated comments
.github/workflows/review-pr.yml actions: write on the review job (also fixes the silently failing feedback-artifact list/delete calls)
.github/workflows/self-review-pr.yml, .github/workflows/test-e2e-reviewer.yml Callers grant actions: write since the called job now requests it
AGENTS.md, SECURITY.md Lock documentation updated (previous text also claimed an outdated 1800 s agent timeout)

Validation

  • actionlint passes (only pre-existing warning about unbuilt dist/)
  • All modified YAML parses
  • Lock bash blocks exercised under bash -e -o pipefail with a stubbed gh across 10 scenarios: fresh, stale, released and corrupt lock states, plus release with list-fail and delete-fail tokens; all pass and the marker is always written
  • Existing tests/*.sh pass

Compatibility

  • Consumers calling the reusable workflow must grant actions: write at the call site, as the review-pr/README.md and SKILL templates already instruct. Non-compliant callers get an explicit permissions validation error instead of silent lock breakage.
  • Legacy lock entries saved by the old code are handled by the TTL branch; a marker entry read by old code behaves like the old 600 s dead zone at worst. No duplicate reviews can result from version mixing during rollout.

…ash-safe

The concurrent-review lock was broken in both directions:
- TTL (600s) was shorter than the agent timeout (2700s), so requests
  arriving mid-review saw a stale lock and started duplicate reviews.
- Release relied on DELETE /actions/caches, which needs actions:write;
  the review job token lacked it, so the delete failed silently and
  every request within 600s of a completed review was silently skipped.

Release now saves a "-released" marker cache entry that shadows the lock
entry on prefix restore (cache saves use the runner cache-service token,
independent of github.token scopes). The REST DELETE remains as logged,
best-effort cleanup. The TTL becomes a crash-only fallback raised to
3600s, above the agent timeout. The review job now requests
actions: write, which callers already grant per the documented setup.
@Sayt-0 Sayt-0 requested a review from derekmisler July 6, 2026 16:18

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

The lock lifecycle logic is sound. The new released-marker mechanism correctly addresses both failure modes described in the PR:

  • Fail-open (duplicate reviews): TTL raised to 3600 s — safely above the 2700 s agent timeout plus overhead, ensuring an in-flight review is never treated as stale.
  • Fail-closed (silent skips): Release no longer depends on actions:write — the cache-service token writes the -released marker entry, which the restore step picks up as the newest prefix match on the next run.

Specific points checked:

  • Step ID cross-references: steps.lock-check and steps.acquire-lock correctly match the id: lock-check and id: acquire-lock fields added in this diff. Hyphens in step IDs are valid in GitHub Actions expressions.
  • rm -f .cache/review-lock/released in acquire: Correctly prevents the newly-saved lock entry from being misread as a release by concurrent runs. The released file is only removed from the runner's local disk; the previous release marker still exists in the cache service and remains restorable by other runs.
  • for CACHE_ID in $CACHE_IDS loop: Empty-string expansion in bash produces zero iterations — safe when gh api returns no results or fails.
  • Restore-keys prefix match: The -released suffix on the marker key still starts with the restore prefix (pr-review-lock-<repo>-<pr>-), so the restore step correctly picks it up as the newest entry after a completed review.
  • DELETE-before-save ordering: A successful DELETE followed by a failed marker save leaves no lock entry in cache, so a concurrent run finds no match and proceeds to acquire — correct behavior.
  • Re-run TTL trade-off: The 3600 s TTL means a re-run after a crash must wait up to 60 min (vs. 10 min with the old 600 s TTL). This is an intentional, explicitly documented design decision: the TTL must exceed the 2700 s agent timeout to prevent a long-running review from being treated as stale. The run_attempt-based save key allows attempt N to save its own lock once it proceeds past the check.

@derekmisler derekmisler left a comment

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.

🤖 Automated PR Reviewthis comment was posted by the pr-reviewer bot, not by a human or the implementer agent

Assessment: 🟡 NEEDS ATTENTION

The core design is sound: the marker-based release mechanism cleanly sidesteps the actions:write dependency for the lock itself, the run_attempt key addition is correct, the steps.acquire-lock.outcome == 'success' guard is an improvement over the prior skip != 'true' check, and raising the TTL to 3600 s (safely above the 2700 s agent timeout) is the right call. Two medium-severity issues found in the new code:

  1. Race window in the two-step release — the DELETE→marker-save sequence introduces a new release-phase race not present in the original design (details below on the marker step).
  2. actions: write grants more than needed — the permission covers workflow-run cancellation/deletion in addition to cache cleanup and artifact management; the artifact delete use-case is a real functional dependency but the blast radius on fork-PR workflow_run jobs is worth noting (details below on review-pr.yml).

Comment thread review-pr/action.yml
fi

mkdir -p .cache/review-lock
touch .cache/review-lock/released

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.

🤖 Automated PR Reviewthis comment was posted by the pr-reviewer bot, not by a human or the implementer agent

[MEDIUM] Release-phase race: marker can shadow a concurrently acquired lock

The two-step release (DELETE in "Release review lock" → cache save here) introduces a new race window that the original single-step release did not have:

  1. Run A finishes, executes DELETE at T1 (removes its lock entry)
  2. Run D starts at T2, sees no lock, acquires its own lock entry ...D-1 (T1 < T2)
  3. Run A saves this marker ...A-1-released at T3 (T2 < T3)
  4. Run E starts at T4 and restores with the prefix → cache returns A's marker (newest by creation time) → sees released file → proceeds
  5. Run D and Run E are now concurrent — duplicate review

The original code (single DELETE step) only had a start-time race (before any lock existed). This adds a release-phase race: the window between A's DELETE completing and A's marker being saved is a gap where a new lock can be established and then immediately shadowed. Reviews are idempotent so the impact is a redundant PR comment rather than data corruption, but it is a genuine regression in the concurrency guarantees.

Possible mitigations:

  • Write the released file and save the marker in one atomic step (move touch .cache/review-lock/released into the bash block that also does the mkdir, then save the marker before the DELETE). With this order: marker is saved first → subsequent restores always see marker → then DELETE cleans up old entries; the fresh marker is never removed because it was saved after the list was captured.
  • Alternatively, store the releasing run_id in the released file. In the evaluate step, treat a released marker as "proceed" only if the releasing run's lock entry (...-{releasing_run_id}-{run_attempt}) is also found (or the entry is absent). This lets concurrent readers distinguish "A released" from "D locked + A released later".

id-token: write
checks: write
actions: write # cache delete for review-lock release cleanup; feedback artifact list/delete
outputs:

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.

🤖 Automated PR Reviewthis comment was posted by the pr-reviewer bot, not by a human or the implementer agent

[MEDIUM] actions: write grants workflow-cancellation scope beyond cache management

actions: write on github.token covers more than cache and artifact management. It also grants:

  • POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel — cancel any workflow run
  • DELETE /repos/{owner}/{repo}/actions/runs/{run_id} — delete workflow run history

actions/cache/save (the actual lock release mechanism — the marker step) uses the runner's built-in ACTIONS_RUNTIME_TOKEN (the cache service token), not github.token. So actions: write on github.token is NOT required for the cache marker to work.

The permission is needed here for two things: (a) the best-effort gh api DELETE cache-entry cleanup (already stated to "fail harmlessly" without it) and (b) the feedback artifact list/delete in the learning loop — that's a real functional dependency that can't simply be dropped.

The concern is in self-review-pr.yml specifically: that workflow uses a workflow_run trigger to process untrusted fork-PR content (PR titles, bodies, code diffs) through the LLM agent, and GH_TOKEN is exposed to the agent. With actions: write, a successful prompt-injection attack gains the ability to cancel or delete any workflow run in the repo. The attack requires compromising the agent, which is behind sanitization, but the blast radius is materially larger than actions: read.

Possible mitigations without breaking feedback artifact deletion:

  • Split permissions so artifact delete uses a separate step with a scoped token (e.g. use GITHUB_TOKEN with actions: write only in a post-step that runs after the agent exits, not during it).
  • Accept the risk with a comment documenting it explicitly — the feedback artifact dependency is real and removing actions: write would break the learning loop. At minimum, documenting this trade-off in SECURITY.md would be valuable.

This also applies to self-review-pr.yml (line 36) and test-e2e-reviewer.yml (line 37), which call this reusable workflow with actions: write at the call site.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants