fix(action): dedupe concurrent checkout_pr + guard cross-PR clobber (#735)
* fix(action): dedupe concurrent checkout_pr calls + guard cross-PR clobber (#642) agents occasionally emit duplicate parallel `checkout_pr` tool_use blocks in one turn, causing two `checkoutPrBranch` invocations to race the same `.git/shallow.lock` and one to fail with `File exists`. the prior fix (#564) added a 30s staleness sweep, but that very threshold protects the within-run concurrent case from itself. dedupe at the tool layer: a module-level `Map<pull_number, Promise>` shares a single in-flight promise across concurrent same-PR calls. the fetch race becomes architecturally impossible — first call does the work, duplicate gets the same `CheckoutPrResult`. cleared in `finally` so subsequent same-PR calls re-do the work normally. also reject cross-PR checkouts when the working tree is dirty, surfacing a clear error instead of silently overwriting uncommitted work from a prior PR. uses existing `toolState.issueNumber` (no new state). * review: use dedicated `pullNumber` toolState field for cross-PR guard per copilot review: the prior guard used `toolState.issueNumber`, which is also set by issue/comment lookup tools (issueInfo, issueComments, issueEvents, review). that conflation is intentional and correct for its only consumer (`report_progress` falls back to `issueNumber` to choose which issue/PR to comment on, and GitHub treats both via the same comment API). but it makes the field wrong for the cross-PR guard: a same-PR re-checkout after `get_issue(other)` would falsely fire and surface a misleading "from PR #other" message. introduce a separate `pullNumber` field, set only by `checkoutPrBranch` alongside `issueNumber` and `checkoutSha`. narrower invariant, no disturbance to the existing `issueNumber` semantics. * review: drop dual-write — single `issueNumber` is sufficient for the guard reverting the `pullNumber` addition. setting both `issueNumber` and `pullNumber` to the same value at the same site was a code smell — there is no scenario where they diverge. issues and PRs share GitHub's number space, and the cross-PR guard's actual job is "refuse to clobber a dirty tree when switching to a different number"; that's expressible with `issueNumber` alone. addresses copilot's original concern (misleading "from PR #X" message when X was an issue) by removing the prior-number reference from the error message entirely. the dirty paths are the actionable detail.
This commit is contained in:
committed by
pullfrog[bot]
parent
ba7f5a0b89
commit
74b7329f64
+55
-10
@@ -603,17 +603,18 @@ export async function checkoutPrBranch(
|
|||||||
return { hookWarning: postCheckoutHook.warning };
|
return { hookWarning: postCheckoutHook.warning };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dedupes concurrent `checkout_pr` calls for the same PR. agents (notably
|
||||||
|
* Sonnet/Claude) occasionally emit duplicate parallel tool_use blocks for the
|
||||||
|
* same args in one turn; without this, both invocations race
|
||||||
|
* `checkoutPrBranch` against the same `.git/shallow.lock` and one fails with
|
||||||
|
* `File exists` (issue #642). cleared in `finally` so subsequent same-PR
|
||||||
|
* calls re-do the work normally.
|
||||||
|
*/
|
||||||
|
const inFlightCheckouts = new Map<number, Promise<CheckoutPrResult>>();
|
||||||
|
|
||||||
export function CheckoutPrTool(ctx: ToolContext) {
|
export function CheckoutPrTool(ctx: ToolContext) {
|
||||||
return tool({
|
const runCheckout = async (pull_number: number): Promise<CheckoutPrResult> => {
|
||||||
name: "checkout_pr",
|
|
||||||
description:
|
|
||||||
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
|
|
||||||
"Returns diffPath pointing to the formatted diff file. " +
|
|
||||||
"Example: `checkout_pr({ pull_number: 1234 })`. " +
|
|
||||||
"Transient fetch timeouts are common — retry the same call up to a few times before treating the failure as terminal. " +
|
|
||||||
"If the error mentions `.git/shallow.lock: File exists` or `.git/index.lock: File exists`, that's a stale lock from a prior timed-out fetch — remove it via the shell tool (`rm -f .git/shallow.lock .git/index.lock`) and retry.",
|
|
||||||
parameters: CheckoutPr,
|
|
||||||
execute: execute(async ({ pull_number }) => {
|
|
||||||
const prResponse = await ctx.octokit.rest.pulls.get({
|
const prResponse = await ctx.octokit.rest.pulls.get({
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
@@ -786,6 +787,50 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
hookWarningInstructions +
|
hookWarningInstructions +
|
||||||
commitLogInstructions,
|
commitLogInstructions,
|
||||||
} satisfies CheckoutPrResult;
|
} satisfies CheckoutPrResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
name: "checkout_pr",
|
||||||
|
description:
|
||||||
|
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
|
||||||
|
"Returns diffPath pointing to the formatted diff file. " +
|
||||||
|
"Example: `checkout_pr({ pull_number: 1234 })`. " +
|
||||||
|
"Transient fetch timeouts are common — retry the same call up to a few times before treating the failure as terminal. " +
|
||||||
|
"If the error mentions `.git/shallow.lock: File exists` or `.git/index.lock: File exists`, that's a stale lock from a prior timed-out fetch — remove it via the shell tool (`rm -f .git/shallow.lock .git/index.lock`) and retry.",
|
||||||
|
parameters: CheckoutPr,
|
||||||
|
execute: execute(async ({ pull_number }) => {
|
||||||
|
const inFlight = inFlightCheckouts.get(pull_number);
|
||||||
|
if (inFlight) {
|
||||||
|
log.info(`» checkout_pr({pull_number:${pull_number}}) already in flight — sharing result`);
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// refuse to clobber an active checkout if the working tree is dirty —
|
||||||
|
// forces the agent to commit/push or discard before switching contexts
|
||||||
|
// instead of silently overwriting uncommitted work. `issueNumber`
|
||||||
|
// tracks any issue/PR the agent has touched (issues and PRs share
|
||||||
|
// GitHub's number space); the guard fires only when the agent is
|
||||||
|
// switching to a *different* number with a dirty tree, which captures
|
||||||
|
// the legitimate "stop, you have unsaved work" case regardless of
|
||||||
|
// whether the prior number was an issue or a PR.
|
||||||
|
const current = ctx.toolState.issueNumber;
|
||||||
|
if (current !== undefined && current !== pull_number) {
|
||||||
|
const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
|
||||||
|
if (dirty) {
|
||||||
|
throw new Error(
|
||||||
|
`cannot checkout PR #${pull_number} while the working tree has uncommitted changes. ` +
|
||||||
|
`commit, push, or discard them before switching. dirty paths:\n${dirty}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = runCheckout(pull_number);
|
||||||
|
inFlightCheckouts.set(pull_number, promise);
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
inFlightCheckouts.delete(pull_number);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user