pr-shepherd 0.56.3 → 0.56.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli/fix-formatter.mjs +6 -0
  4. package/bin/cli/iterate-lean.mjs +3 -0
  5. package/bin/cli/iterate-merge-formatter.mjs +2 -0
  6. package/bin/commands/apply-queue-removal.mjs +3 -4
  7. package/bin/commands/check.mjs +15 -11
  8. package/bin/commands/iterate/fix-code.mjs +27 -17
  9. package/bin/commands/iterate/index.mjs +1 -0
  10. package/bin/commands/iterate/merge.mjs +3 -0
  11. package/bin/commands/iterate/merged-base-pull-requests.d.mts +8 -0
  12. package/bin/commands/iterate/merged-base-pull-requests.mjs +24 -0
  13. package/bin/commands/iterate/native-stack-rebase.d.mts +5 -2
  14. package/bin/commands/iterate/native-stack-rebase.mjs +11 -2
  15. package/bin/commands/iterate/queue-recovery-instructions.d.mts +8 -0
  16. package/bin/commands/iterate/queue-recovery-instructions.mjs +38 -0
  17. package/bin/commands/iterate/render.d.mts +3 -1
  18. package/bin/commands/iterate/render.mjs +13 -16
  19. package/bin/commands/iterate/stall.mjs +2 -0
  20. package/bin/commands/iterate/unreported-required.d.mts +2 -0
  21. package/bin/commands/iterate/unreported-required.mjs +1 -1
  22. package/bin/github/batch-parsers.mjs +6 -0
  23. package/bin/github/batch-raw-rules.d.mts +12 -0
  24. package/bin/github/gql/batch-pr.gql +14 -0
  25. package/bin/github/gql/merged-base-pull-requests.gql +31 -0
  26. package/bin/github/gql/poll-summary-fragment.gql +7 -0
  27. package/bin/github/merge-queue-checks.mjs +2 -1
  28. package/bin/github/poll-summary-queue-removal.mjs +2 -1
  29. package/bin/github/poll-summary-raw.d.mts +6 -0
  30. package/bin/github/queries.d.mts +2 -0
  31. package/bin/github/queries.mjs +2 -0
  32. package/bin/github/queue-removal-freshness.d.mts +24 -6
  33. package/bin/github/queue-removal-freshness.mjs +34 -12
  34. package/bin/state/ci-retrigger.d.mts +6 -4
  35. package/bin/state/ci-retrigger.mjs +6 -4
  36. package/bin/state/queue-removal-ack.d.mts +5 -1
  37. package/bin/state/queue-removal-ack.mjs +6 -2
  38. package/bin/types/github.d.mts +17 -0
  39. package/bin/types/iterate.d.mts +3 -1
  40. package/bin/types/merge-queue.d.mts +2 -0
  41. package/package.json +1 -1
  42. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  43. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  44. package/plugins/pr-shepherd/.mcp.json +1 -1
  45. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -0
  46. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -1
  47. package/plugins/pr-shepherd/skills/pr-shepherd/references/merge-queue-ejection.md +17 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.56.3",
4
+ "version": "0.56.5",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -164,7 +164,7 @@ receipts, and whose bottom open layer GitHub has retargeted onto the stack base,
164
164
  with `gh stack merge <that PR number> --yes` and the allowed method flag (`--squash` unless config or the repository selects another). That lands the named layer and every
165
165
  unmerged layer below it. When the base uses a merge queue, the same command queues the prefix
166
166
  together and GitHub evaluates each layer from the bottom; a failure ejects that layer and those
167
- above it. If the evidence shows an unrelated failure and no source changes or other blockers remain, the one-PR session emits a head-, queue-commit-, and timestamp-pinned local acknowledgment command. Fresh source checks and a new READY receipt then let the aggregate selector recover the eligible prefix. Manual or stale removals cannot use this path. Layers above the prefix keep their one-PR sessions. After the merge, GitHub retargets
167
+ above it. For a `failed_checks` removal on the layer's first ejection, the one-PR session emits a head-, queue-commit-, and timestamp-pinned local acknowledgment command; run it only when the failure does not reproduce after updating from the latest base, the head did not change, and no source changes or other blockers remain. Fresh source checks and a new READY receipt then let the aggregate selector recover the eligible prefix. Manual or stale removals cannot use this path. Layers above the prefix keep their one-PR sessions. After the merge, GitHub retargets
168
168
  the next layer, so the rerun continues until the stack returns `CANCEL`. API and MCP aggregate
169
169
  calls perform one summary tick and leave recurrence to the caller.
170
170
 
@@ -14,6 +14,12 @@ export function formatFixCodeResult(header, result, opts = {}) {
14
14
  const verbose = opts.verbose ?? false;
15
15
  const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
16
16
  const sections = [header];
17
+ if (result.mergedBasePullRequests?.length) {
18
+ sections.push("## Merged PRs matching the current base");
19
+ sections.push(result.mergedBasePullRequests
20
+ .map((pr) => `- [#${pr.number}](${pr.url}) · state \`${pr.state}\` · head \`${pr.headRefName}\` at \`${pr.headRefOid}\` · base \`${pr.baseRefName}\` · mergedAt \`${pr.mergedAt}\` · headRepository \`${pr.headRepository.nameWithOwner}\``)
21
+ .join("\n"));
22
+ }
17
23
  const renderThreads = (heading, threads) => {
18
24
  if (threads.length === 0)
19
25
  return;
@@ -43,6 +43,9 @@ export function projectIterateLean(result, opts) {
43
43
  }),
44
44
  ...(result.baseBranch && { baseBranch: result.baseBranch }),
45
45
  ...(result.stackTrunkConflict && { stackTrunkConflict: result.stackTrunkConflict }),
46
+ ...((result.mergedBasePullRequests?.length ?? 0) > 0 && {
47
+ mergedBasePullRequests: result.mergedBasePullRequests,
48
+ }),
46
49
  ...(result.branchProtection !== null && { branchProtection: result.branchProtection }),
47
50
  ...(result.mergeRequirements && { mergeRequirements: result.mergeRequirements }),
48
51
  ...(result.mergeQueue && { mergeQueue: result.mergeQueue }),
@@ -40,6 +40,8 @@ export function appendMergeQueueHeader(lines, result) {
40
40
  parts.push("checks incomplete (first 100 shown)");
41
41
  if (queue.headUpdatedAfterRemoval)
42
42
  parts.push("head updated after removal");
43
+ if (queue.removalsOnHead)
44
+ parts.push(`removals on this head \`${queue.removalsOnHead}\``);
43
45
  if (queue.removalAcknowledged)
44
46
  parts.push("removal acknowledged");
45
47
  lines.push(`**merge queue** ${parts.join(" · ")}`);
@@ -24,10 +24,9 @@ export async function applyQueueRemovalAck(input) {
24
24
  !queueRemovalAppliesToHead({
25
25
  parentOids: removal.beforeCommitParentOids,
26
26
  headOid: data.headRefOid,
27
- ...(data.activity?.latestCommitCommittedAtUnix != null && {
28
- headCommittedAtUnix: data.activity.latestCommitCommittedAtUnix,
29
- }),
30
- ...(data.headPushedAtUnix !== undefined && { headPushedAtUnix: data.headPushedAtUnix }),
27
+ headCommittedAtUnix: data.activity?.latestCommitCommittedAtUnix,
28
+ headPushedAtUnix: data.headPushedAtUnix,
29
+ headForcePushedAtUnix: data.headForcePushedAtUnix,
31
30
  removedAtUnix: removal.createdAtUnix,
32
31
  })) {
33
32
  throw new ShepherdError("The supplied queue-removal evidence is stale or is not a current CI-driven removal for this native-stack PR.", EXIT.UNAVAILABLE);
@@ -1,5 +1,5 @@
1
1
  import { fetchPrBatch } from "../github/batch.mjs";
2
- import { queueRemovalAppliesToHead } from "../github/queue-removal-freshness.mjs";
2
+ import { headArrivalUnix, queueRemovalAppliesToHead } from "../github/queue-removal-freshness.mjs";
3
3
  import { readQueueRemovalAcknowledgment, matchesQueueRemovalAcknowledgment, isCiQueueRemovalReason, } from "../state/queue-removal-ack.mjs";
4
4
  import { storePrFingerprint } from "../state/pr-fingerprint.mjs";
5
5
  import { tryReuseFingerprintReport } from "./check-fingerprint.mjs";
@@ -76,22 +76,25 @@ export async function runCheck(opts, context) {
76
76
  // it as stale/updated rather than as still current, so Shepherd doesn't escalate
77
77
  // `merge-queue-removed` permanently on data it can no longer check. A squash or rebase
78
78
  // queue commit has one parent and does not list the PR head; that removal stays current
79
- // until this head reached the PR after it. The push time is the earliest pull_request
80
- // check on the head, with committer time as the fallback when no check time is
81
- // available. The raw removal fields still render in the merge-queue header regardless
82
- // of this flag.
83
- const headCommittedAtUnix = batchData.activity?.latestCommitCommittedAtUnix;
79
+ // until this head reached the PR after it (`headArrivalUnix`). The raw removal fields
80
+ // still render in the merge-queue header regardless of this flag.
81
+ const headTimes = {
82
+ headCommittedAtUnix: batchData.activity?.latestCommitCommittedAtUnix,
83
+ headPushedAtUnix: batchData.headPushedAtUnix,
84
+ headForcePushedAtUnix: batchData.headForcePushedAtUnix,
85
+ };
84
86
  const headUpdatedAfterRemoval = Boolean(latestRemoval &&
85
87
  !queueRemovalAppliesToHead({
88
+ ...headTimes,
86
89
  parentOids: latestRemoval.beforeCommitParentOids,
87
90
  headOid: batchData.headRefOid,
88
- ...(headCommittedAtUnix !== undefined &&
89
- headCommittedAtUnix !== null && { headCommittedAtUnix }),
90
- ...(batchData.headPushedAtUnix !== undefined && {
91
- headPushedAtUnix: batchData.headPushedAtUnix,
92
- }),
93
91
  removedAtUnix: latestRemoval.createdAtUnix,
94
92
  }));
93
+ // Repeat ejections of this head: removals after it reached the PR.
94
+ const headSinceUnix = headArrivalUnix(headTimes);
95
+ const removalsOnHead = latestRemoval && !headUpdatedAfterRemoval && headSinceUnix !== undefined
96
+ ? (batchData.mergeQueueRemovalTimesUnix ?? []).filter((t) => t >= headSinceUnix).length
97
+ : 0;
95
98
  const removalAcknowledged = Boolean(batchData.stack &&
96
99
  latestRemoval?.beforeCommitOid &&
97
100
  isCiQueueRemovalReason(latestRemoval.reason) &&
@@ -367,6 +370,7 @@ export async function runCheck(opts, context) {
367
370
  checksIncomplete: true,
368
371
  }),
369
372
  ...(headUpdatedAfterRemoval && { headUpdatedAfterRemoval: true }),
373
+ ...(removalsOnHead > 1 && { removalsOnHead }),
370
374
  ...(removalAcknowledged && { removalAcknowledged: true }),
371
375
  },
372
376
  }),
@@ -8,9 +8,11 @@ import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thre
8
8
  import { buildFixInstructions } from "./render.mjs";
9
9
  import { buildRemovedQueueRecovery, buildStackQueueRemovalAcknowledgment } from "./merge.mjs";
10
10
  import { hasLogEvidence } from "./check-evidence.mjs";
11
+ import { currentEjectionCommit, } from "./queue-recovery-instructions.mjs";
11
12
  import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
12
13
  import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
13
14
  import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
15
+ import { lookupMergedBasePullRequests } from "./merged-base-pull-requests.mjs";
14
16
  import { conflictingHeadCiNote, countReportedChecks, insertConflictingHeadCiNote, } from "./conflicting-head-ci.mjs";
15
17
  import { conflictingHeadFirstSeenUnix } from "../../state/conflicting-head-seen.mjs";
16
18
  import { applyStallGuard } from "./stall.mjs";
@@ -291,6 +293,14 @@ export async function handleFixCode(ctx) {
291
293
  const firstLookComments = report.comments.firstLook;
292
294
  const stack = report.mergeStatus.mergeRequirements?.stack;
293
295
  const headRef = report.headSha;
296
+ const mergedBasePullRequests = hasConflicts && !stack && report.baseRefOid
297
+ ? await lookupMergedBasePullRequests({
298
+ owner: repoOwner,
299
+ repo: repoName,
300
+ baseRefName: baseLookup.branch,
301
+ baseRefOid: report.baseRefOid,
302
+ })
303
+ : [];
294
304
  // Only an upper layer can be dirty against trunk while already containing its parent.
295
305
  const trunkConflict = hasConflicts && stack && headRef && baseLookup.branch !== stack.baseRefName
296
306
  ? await lookupUpperLayerTrunkConflict({
@@ -301,25 +311,21 @@ export async function handleFixCode(ctx) {
301
311
  trunk: stack.baseRefName,
302
312
  })
303
313
  : undefined;
304
- // Conflicts, and a behind branch whose rerun already failed, both ask for a branch update.
305
- const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0)
306
- ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
307
- : undefined;
308
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0, stackRebase);
309
314
  const requeue = buildRemovedQueueRecovery(report, failingAgentChecks, opts.merge);
310
315
  const queueRemovalAcknowledgment = buildStackQueueRemovalAcknowledgment(report, failingAgentChecks);
311
- if (queueRemovalAcknowledgment) {
312
- const completion = instructions.pop();
313
- instructions.push("If the merge-group failure belongs to this PR, fix and push its head, then iterate. Otherwise, if no code changed and no other blocker remains, run `acknowledge queue removal:` exactly as printed. This records only the disposition of that removed queue commit; finish this one-PR session to validate current source CI and record its READY receipt, then return to the aggregate `--stack` selector with its original options. In merge mode it verifies lower-layer readiness before merging. Do not enqueue or merge this layer directly.");
314
- if (completion !== undefined)
315
- instructions.push(completion);
316
- }
317
- if (requeue) {
318
- const completion = instructions.pop();
319
- instructions.push("If the merge-group failure belongs to this PR, fix and push the PR head, then iterate. Otherwise, if no code changed and no other blocker remains, run the `requeue:` command exactly as printed. If gh reports auto-merge is disabled instead of adding the PR to the queue, run the `requeue API fallback:` command. Both commands require the observed PR head SHA; if the head changed, iterate for a fresh command.");
320
- if (completion !== undefined)
321
- instructions.push(completion);
322
- }
316
+ const queueEjection = !currentEjectionCommit(report, failingAgentChecks)
317
+ ? undefined
318
+ : requeue
319
+ ? "requeue"
320
+ : queueRemovalAcknowledgment
321
+ ? "acknowledge"
322
+ : "none";
323
+ // Conflicts, a behind branch whose rerun already failed, and a queue ejection that may be
324
+ // reproduced on the latest base all print the branch update route.
325
+ const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0) || queueEjection
326
+ ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
327
+ : undefined;
328
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0, stackRebase, queueEjection);
323
329
  if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
324
330
  const completion = instructions.pop();
325
331
  instructions.push(buildReleasedBlockerInstruction(prNumber));
@@ -329,6 +335,9 @@ export async function handleFixCode(ctx) {
329
335
  if (repairInstructions && repairInstructions.length > 0) {
330
336
  instructions.unshift(...repairInstructions);
331
337
  }
338
+ if (mergedBasePullRequests.length > 0) {
339
+ instructions.unshift(`Inspect every PR under \`## Merged PRs matching the current base\`. If this PR is the remaining layer intended for a merged parent's base branch, run \`gh pr edit ${prReference} --base <verified-parent-base>\` after replacing \`<verified-parent-base>\` with that parent's shell-quoted base branch, then rerun Shepherd immediately and follow its fresh instructions instead of the remaining steps here. Otherwise keep the current base and follow the remaining conflict-resolution steps.`);
340
+ }
332
341
  const checkRunCount = countReportedChecks(report.checks);
333
342
  const suitesEmpty = report.headCheckSuitesEmpty === true;
334
343
  const nowMs = Date.now();
@@ -345,6 +354,7 @@ export async function handleFixCode(ctx) {
345
354
  const prospectiveResult = {
346
355
  ...base,
347
356
  baseBranch: baseLookup.branch,
357
+ ...(mergedBasePullRequests.length > 0 && { mergedBasePullRequests }),
348
358
  ...(trunkConflict && { stackTrunkConflict: trunkConflict.trunk }),
349
359
  action: "fix_code",
350
360
  fix: {
@@ -135,6 +135,7 @@ async function runIterateCore(opts) {
135
135
  stateKey: stallKey,
136
136
  headSha,
137
137
  otherAutonomousWork: hasActionableWork || staleAncestry !== null,
138
+ persistState: opts.persistSeen !== false,
138
139
  });
139
140
  if (unreportedPlan.escalate)
140
141
  return unreportedPlan.escalate;
@@ -99,6 +99,9 @@ function removedQueueRecoveryAvailable(report, checks, merge) {
99
99
  !queue?.enabled ||
100
100
  queue.inQueue ||
101
101
  queue.headUpdatedAfterRemoval ||
102
+ // A repeat ejection of the same head is not retried blindly; a conflict forces a new head.
103
+ (queue.removalsOnHead ?? 1) > 1 ||
104
+ report.mergeStatus.status === "CONFLICTS" ||
102
105
  // GitHub exposes a raw string, not a capability to reverse a human's queue removal.
103
106
  // Only known CI-driven reasons authorize offering automated recovery.
104
107
  !isCiQueueRemovalReason(queue.latestRemoval?.reason) ||
@@ -0,0 +1,8 @@
1
+ import type { MergedBasePullRequest } from "../../types.mts";
2
+ /** A bounded, best-effort lookup; matching OIDs and repository prevent branch-name reuse false positives. */
3
+ export declare function lookupMergedBasePullRequests(input: {
4
+ owner: string;
5
+ repo: string;
6
+ baseRefName: string;
7
+ baseRefOid: string;
8
+ }): Promise<MergedBasePullRequest[]>;
@@ -0,0 +1,24 @@
1
+ import { graphql } from "../../github/client.mjs";
2
+ import { MERGED_BASE_PULL_REQUESTS_QUERY } from "../../github/queries.mjs";
3
+ import { pollRateLimitRetryAfterMs } from "../poll-quota.mjs";
4
+ /** A bounded, best-effort lookup; matching OIDs and repository prevent branch-name reuse false positives. */
5
+ export async function lookupMergedBasePullRequests(input) {
6
+ try {
7
+ const { data } = await graphql(MERGED_BASE_PULL_REQUESTS_QUERY, {
8
+ owner: input.owner,
9
+ repo: input.repo,
10
+ branch: input.baseRefName,
11
+ });
12
+ return (data.repository?.pullRequests.nodes ?? []).filter((candidate) => candidate !== null &&
13
+ candidate.state === "MERGED" &&
14
+ candidate.headRefName === input.baseRefName &&
15
+ candidate.headRefOid === input.baseRefOid &&
16
+ candidate.headRepository?.nameWithOwner === `${input.owner}/${input.repo}`);
17
+ }
18
+ catch (error) {
19
+ if (pollRateLimitRetryAfterMs(error) !== null)
20
+ throw error;
21
+ process.stderr.write(`pr-shepherd: merged-base PR lookup unavailable (ignored): ${error instanceof Error ? error.message : String(error)}\n`);
22
+ return [];
23
+ }
24
+ }
@@ -36,5 +36,8 @@ export declare function buildNativeStackLayerRebase(repo: string, pr: {
36
36
  }): string | undefined;
37
37
  /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
38
38
  export declare function buildConflictInstruction(stackRebase: string | undefined): string;
39
- /** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
40
- export declare function buildBranchPushInstruction(stackRebase: string | undefined, hasConflicts: boolean, mutationSuffix: string): string;
39
+ /**
40
+ * Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack.
41
+ * After a merge-queue ejection update, push only when the update or a fix changed the head.
42
+ */
43
+ export declare function buildBranchPushInstruction(stackRebase: string | undefined, hasConflicts: boolean, mutationSuffix: string, ejectionUpdate?: boolean): string;
@@ -44,8 +44,17 @@ export function buildConflictInstruction(stackRebase) {
44
44
  const pointer = "The branch has merge conflicts (see `**branch**` above).";
45
45
  return stackRebase ? `${pointer} ${stackRebase}` : `${pointer} Resolve them before committing.`;
46
46
  }
47
- /** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
48
- export function buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix) {
47
+ /**
48
+ * Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack.
49
+ * After a merge-queue ejection update, push only when the update or a fix changed the head.
50
+ */
51
+ export function buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix, ejectionUpdate = false) {
52
+ if (ejectionUpdate) {
53
+ const push = stackRebase
54
+ ? "commit any remaining changes on the PR head branch and push the rewritten stack with `gh stack push`"
55
+ : "commit any remaining changes and push to the PR head branch";
56
+ return `If the base update or a fix changed the head, ${push}${mutationSuffix}. If neither did, do not push.`;
57
+ }
49
58
  if (stackRebase)
50
59
  return `Commit any remaining changes on the PR head branch and push the rewritten stack with \`gh stack push\`${mutationSuffix}.`;
51
60
  return hasConflicts
@@ -0,0 +1,8 @@
1
+ /** Instruction text for a PR that GitHub removed from the merge queue after failed checks. */
2
+ import type { AgentCheck, ShepherdReport } from "../../types.mts";
3
+ /** Which recovery command, if any, Shepherd printed for a failure that does not reproduce. */
4
+ export type QueueEjectionRecovery = "requeue" | "acknowledge" | "none";
5
+ /** The removed queue commit whose failed checks this tick surfaces, if the removal is current. */
6
+ export declare function currentEjectionCommit(report: ShepherdReport, checks: AgentCheck[]): string | undefined;
7
+ /** The ejection step after failing-check triage; points at a stack route already printed. */
8
+ export declare function queueEjectionSteps(recovery: QueueEjectionRecovery | undefined, stackRebase: string | undefined, routePrinted: boolean): string[];
@@ -0,0 +1,38 @@
1
+ /** Instruction text for a PR that GitHub removed from the merge queue after failed checks. */
2
+ import { playbookPointer } from "../playbook-pointer.mjs";
3
+ /** The removed queue commit whose failed checks this tick surfaces, if the removal is current. */
4
+ export function currentEjectionCommit(report, checks) {
5
+ const queue = report.mergeQueue;
6
+ const commit = queue?.latestRemoval?.beforeCommitOid;
7
+ if (!commit || queue.inQueue || queue.headUpdatedAfterRemoval)
8
+ return undefined;
9
+ return checks.some((check) => check.scope === "merge_group" && check.commitOid === commit)
10
+ ? commit
11
+ : undefined;
12
+ }
13
+ /** The ejection step after failing-check triage; points at a stack route already printed. */
14
+ export function queueEjectionSteps(recovery, stackRebase, routePrinted) {
15
+ if (!recovery)
16
+ return [];
17
+ const route = stackRebase && routePrinted ? "use the stack route printed above." : stackRebase;
18
+ return [buildQueueEjectionInstruction(recovery, route)];
19
+ }
20
+ /**
21
+ * The trigger, update route, and command guard; the fixed procedure lives in the playbook.
22
+ * A printed command implies a `failed_checks` removal. Without one, the agent reads the raw
23
+ * removal reason, since a person's dequeue must not be undone by a branch update.
24
+ */
25
+ function buildQueueEjectionInstruction(recovery, stackRoute) {
26
+ const target = stackRoute ? "the stack" : "the PR head";
27
+ const route = stackRoute ? `: ${stackRoute}` : ".";
28
+ const update = recovery === "none"
29
+ ? `If the \`**queue removal**\` reason shows GitHub removed the entry itself, update ${target} from the latest base first${route} If a person may have dequeued the PR, skip that update unless a conflict step above requires it.`
30
+ : `Update ${target} from the latest base first${route}`;
31
+ const reproduce = "only if the failure does not reproduce on the updated head, neither the update nor a code change altered the head, and no other blocker remains.";
32
+ const guard = recovery === "requeue"
33
+ ? `Run \`requeue:\` ${reproduce} If gh reports auto-merge is disabled, run \`requeue API fallback:\` instead.`
34
+ : recovery === "acknowledge"
35
+ ? `Run \`acknowledge queue removal:\` ${reproduce}`
36
+ : "Shepherd printed no queue command for this session, so do not enqueue the PR.";
37
+ return `Triage the merge-queue ejection before any requeue. ${update} ${guard} ${playbookPointer("Merge queue ejection")}`;
38
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, FirstLookThread, FirstLookComment, ReviewThread } from "../../types.mts";
2
+ import { type QueueEjectionRecovery } from "./queue-recovery-instructions.mts";
2
3
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
3
4
  export declare function renderResolveCommand(rc: ResolveCommand): string;
4
5
  export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, _cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], _inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
- isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean, stackRebase?: string): string[];
6
+ isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean, stackRebase?: string, // native stack layers rebase with gh-stack, not branch by branch
7
+ queueEjection?: QueueEjectionRecovery): string[];
@@ -5,6 +5,7 @@ import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
5
5
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
6
6
  import { partitionFixThreads, reviewSectionRefs } from "./fix-instruction-threads.mjs";
7
7
  import { buildBranchPushInstruction, buildConflictInstruction } from "./native-stack-rebase.mjs";
8
+ import { queueEjectionSteps } from "./queue-recovery-instructions.mjs";
8
9
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
9
10
  export function renderResolveCommand(rc) {
10
11
  const parts = [...rc.argv];
@@ -13,7 +14,8 @@ export function renderResolveCommand(rc) {
13
14
  return renderShellCommand(parts);
14
15
  }
15
16
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, _cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], _inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
16
- isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, stackRebase) {
17
+ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, stackRebase, // native stack layers rebase with gh-stack, not branch by branch
18
+ queueEjection) {
17
19
  const instructions = [];
18
20
  const { locatedThreads, unlocatedMutatedThreads, unlocatedThreads } = partitionFixThreads(threads, resolveCommand, resolveOnlyCommand);
19
21
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
@@ -45,25 +47,22 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
45
47
  });
46
48
  // The conflict hint belongs with the conflict step; otherwise it precedes the push step.
47
49
  const hintWithConflictStep = hasConflicts && !hasRepeatedWorkflowBranchRecovery;
48
- if (hintWithConflictStep) {
50
+ const branchRecovery = hasConflicts || hasRepeatedWorkflowBranchRecovery;
51
+ if (hintWithConflictStep)
49
52
  instructions.push(buildConflictInstruction(stackRebase), ...branchUpdateHint);
50
- }
51
53
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
52
- if (firstLookTotal > 0) {
54
+ if (firstLookTotal > 0)
53
55
  instructions.push("Review every item under `## First-look items` before acting.");
54
- }
55
56
  if (firstLookSummaries.length > 0 && viewerCanUpdate)
56
57
  instructions.push(SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE);
57
58
  const editedTotal = editedSummaries.length +
58
59
  actionableComments.filter((c) => c.edited).length +
59
60
  firstLookThreads.filter((t) => t.edited).length +
60
61
  firstLookComments.filter((c) => c.edited).length;
61
- if (editedTotal > 0) {
62
+ if (editedTotal > 0)
62
63
  instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
63
- }
64
- if (unlocatedThreads.length > 0) {
64
+ if (unlocatedThreads.length > 0)
65
65
  instructions.push("Acknowledge each item under `## Unlocated review threads (logged once — no mutation)`. Shepherd cannot route a code fix or review mutation without a path and line; the unchanged item will be skipped on later ticks.");
66
- }
67
66
  const hasSuggestions = locatedThreads.some((t) => t.suggestion);
68
67
  if (hasSuggestions)
69
68
  instructions.push(buildCommitSuggestionInstruction(prReference, "## Review threads"));
@@ -73,13 +72,11 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
73
72
  const filesRef = locatedThreads.length > 0 ? "each file referenced above" : "the relevant files";
74
73
  instructions.push(`Apply every warranted review fix in ${filesRef}.`);
75
74
  }
76
- if (resolutionOnlyThreads.length > 0) {
75
+ if (resolutionOnlyThreads.length > 0)
77
76
  instructions.push("Review the threads under `## Review threads to resolve` before running the generated mutations.");
78
- }
79
- instructions.push(...buildFailingCheckInstructions(failingChecks), ...repeatedWorkflowBranchRecoveryInstructions);
80
- if (hasAnnotations) {
77
+ instructions.push(...buildFailingCheckInstructions(failingChecks), ...repeatedWorkflowBranchRecoveryInstructions, ...queueEjectionSteps(queueEjection, stackRebase, branchRecovery));
78
+ if (hasAnnotations)
81
79
  instructions.push("Inspect every referenced range under `## Check annotations` and apply any warranted change.");
82
- }
83
80
  if (changesRequestedReviews.length > 0) {
84
81
  const staleClause = buildCrStaleClause(changesRequestedReviews);
85
82
  instructions.push(`Read every body under \`## Changes-requested reviews\` and apply any warranted change.${staleClause}`);
@@ -88,8 +85,8 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
88
85
  instructions.push(...branchUpdateHint);
89
86
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
90
87
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
91
- if (hasConflicts || hasRepeatedWorkflowBranchRecovery) {
92
- instructions.push(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix));
88
+ if (branchRecovery || queueEjection) {
89
+ instructions.push(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix, !branchRecovery));
93
90
  }
94
91
  else if (hasNonConflictHints) {
95
92
  instructions.push("If you changed code, commit any remaining changes and push to the PR head branch. If you did not, do not commit.");
@@ -50,6 +50,8 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
50
50
  state: base.state,
51
51
  isDraft: base.isDraft,
52
52
  checks,
53
+ // A requeued head ejected again gets its own triage window.
54
+ queueRemovalCommit: report.mergeQueue?.latestRemoval?.beforeCommitOid,
53
55
  threads,
54
56
  resolutionOnlyThreads,
55
57
  ruleAutoResolveThreads,
@@ -13,6 +13,8 @@ export declare function planUnreportedRequired(input: {
13
13
  };
14
14
  headSha: string;
15
15
  otherAutonomousWork: boolean;
16
+ /** Internal preview ticks must not consume the one-time reopen opportunity. */
17
+ persistState?: boolean;
16
18
  }): Promise<UnreportedPlan>;
17
19
  export declare function buildUnreportedFixResult(base: IterateResultBase, report: ShepherdReport, instructions: string[]): IterateResult;
18
20
  export {};
@@ -72,7 +72,7 @@ export async function planUnreportedRequired(input) {
72
72
  trunk: (input.report.trunkBehindBy ?? 0) > 0,
73
73
  ...(rebase && { stackRebase: rebase }),
74
74
  });
75
- if (decision === "reopen") {
75
+ if (decision === "reopen" && input.persistState !== false) {
76
76
  await writeCiRetrigger(input.stateKey, { headSha: input.headSha, contexts: names });
77
77
  }
78
78
  return { repairInstructions: instructions };
@@ -118,6 +118,12 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
118
118
  reviewDecision: (raw.reviewDecision ?? null),
119
119
  headRefOid: raw.headRefOid,
120
120
  ...(headPushedAtUnix !== undefined && { headPushedAtUnix }),
121
+ ...(raw.headRefForcePushes?.nodes[0] && {
122
+ headForcePushedAtUnix: Math.floor(Date.parse(raw.headRefForcePushes.nodes[0].createdAt) / 1000),
123
+ }),
124
+ ...(raw.mergeQueueRemovalTimes && {
125
+ mergeQueueRemovalTimesUnix: raw.mergeQueueRemovalTimes.nodes.map((node) => Math.floor(Date.parse(node.createdAt) / 1000)),
126
+ }),
121
127
  headRefName: raw.headRefName,
122
128
  headRepoWithOwner: raw.headRepository?.nameWithOwner ?? null,
123
129
  viewerAuthorization: {
@@ -101,6 +101,18 @@ export interface RawPrMergeFields {
101
101
  createdAt: string;
102
102
  }>;
103
103
  } | null;
104
+ /** Timestamps of the last 10 queue removals; counts repeat ejections of one head. */
105
+ mergeQueueRemovalTimes?: {
106
+ nodes: Array<{
107
+ createdAt: string;
108
+ }>;
109
+ } | null;
110
+ /** The latest force-push, which dates a head whose commit time predates its push. */
111
+ headRefForcePushes?: {
112
+ nodes: Array<{
113
+ createdAt: string;
114
+ }>;
115
+ } | null;
104
116
  stack?: RawStack | null;
105
117
  stackEntry?: {
106
118
  position: number;
@@ -88,6 +88,20 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
88
88
  }
89
89
  }
90
90
  }
91
+ mergeQueueRemovalTimes: timelineItems(last: 10, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) {
92
+ nodes {
93
+ ... on RemovedFromMergeQueueEvent {
94
+ createdAt
95
+ }
96
+ }
97
+ }
98
+ headRefForcePushes: timelineItems(last: 1, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) {
99
+ nodes {
100
+ ... on HeadRefForcePushedEvent {
101
+ createdAt
102
+ }
103
+ }
104
+ }
91
105
  stack {
92
106
  number
93
107
  size
@@ -0,0 +1,31 @@
1
+ query MergedBasePullRequests($owner: String!, $repo: String!, $branch: String!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
10
+ repository(owner: $owner, name: $repo) {
11
+ pullRequests(
12
+ headRefName: $branch
13
+ states: MERGED
14
+ first: 20
15
+ orderBy: { field: UPDATED_AT, direction: DESC }
16
+ ) {
17
+ nodes {
18
+ number
19
+ url
20
+ state
21
+ headRefName
22
+ headRefOid
23
+ baseRefName
24
+ mergedAt
25
+ headRepository {
26
+ nameWithOwner
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
@@ -93,6 +93,13 @@ fragment PollSummaryPr on PullRequest {
93
93
  }
94
94
  }
95
95
  }
96
+ headRefForcePushes: timelineItems(last: 1, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) {
97
+ nodes {
98
+ ... on HeadRefForcePushedEvent {
99
+ createdAt
100
+ }
101
+ }
102
+ }
96
103
  mergeQueueEntry {
97
104
  headCommit {
98
105
  oid
@@ -1,7 +1,7 @@
1
1
  import { parseCreatedAt } from "./batch-parser-helpers.mjs";
2
2
  import { graphqlWithRateLimit } from "./client.mjs";
3
3
  import { GitHubRequestError } from "./errors.mjs";
4
- import { headPushUnixFromCheckNodes, queueRemovalAppliesToHead, } from "./queue-removal-freshness.mjs";
4
+ import { forcePushUnix, headPushUnixFromCheckNodes, queueRemovalAppliesToHead, } from "./queue-removal-freshness.mjs";
5
5
  import { requireContextNodes } from "./batch-response.mjs";
6
6
  import { COMMIT_CHECK_CONTEXTS_QUERY } from "./queries.mjs";
7
7
  function omittedCursorError(oid) {
@@ -79,6 +79,7 @@ function currentRemovalCommit(raw) {
79
79
  headCommittedAtUnix: parseCreatedAt(headCommit.committedDate),
80
80
  }),
81
81
  ...(headPushedAtUnix !== undefined && { headPushedAtUnix }),
82
+ headForcePushedAtUnix: forcePushUnix(raw.headRefForcePushes),
82
83
  removedAtUnix: Math.floor(Date.parse(removal.createdAt) / 1000),
83
84
  })) {
84
85
  return undefined;
@@ -1,4 +1,4 @@
1
- import { headPushUnixFromCheckNodes, queueRemovalAppliesToHead, } from "./queue-removal-freshness.mjs";
1
+ import { forcePushUnix, headPushUnixFromCheckNodes, queueRemovalAppliesToHead, } from "./queue-removal-freshness.mjs";
2
2
  import { parseCreatedAt } from "./batch-parser-helpers.mjs";
3
3
  /** Latest queue removal that still applies to this PR head, not an older attempt. */
4
4
  export function currentQueueRemovalEvent(raw) {
@@ -21,6 +21,7 @@ export function currentQueueRemovalEvent(raw) {
21
21
  headCommittedAtUnix: parseCreatedAt(headCommit.committedDate),
22
22
  }),
23
23
  ...(headPushedAtUnix !== undefined && { headPushedAtUnix }),
24
+ headForcePushedAtUnix: forcePushUnix(raw.headRefForcePushes),
24
25
  removedAtUnix: Math.floor(removalTime / 1000),
25
26
  })) {
26
27
  return null;
@@ -138,6 +138,12 @@ export interface RawSummaryPr {
138
138
  mergeQueueEntry: {
139
139
  headCommit: RawSummaryCommit | null;
140
140
  } | null;
141
+ /** The latest force-push, which dates a head whose commit and check times predate its push. */
142
+ headRefForcePushes?: {
143
+ nodes: Array<{
144
+ createdAt: string;
145
+ }>;
146
+ } | null;
141
147
  stack: {
142
148
  number: number;
143
149
  size: number;
@@ -39,6 +39,8 @@ export declare const POLL_STACK_TOPOLOGY_QUERY: string;
39
39
  * the open stack entries needed to name the bottom layer. Not part of BatchPr.
40
40
  */
41
41
  export declare const UPPER_LAYER_CONFLICT_TARGET_QUERY: string;
42
+ /** Merged PRs whose head may be the stale base of a non-stack conflict. */
43
+ export declare const MERGED_BASE_PULL_REQUESTS_QUERY: string;
42
44
  /** PR head fields plus a single review thread for `commit-suggestion`. */
43
45
  export declare const SUGGESTION_THREADS_QUERY: string;
44
46
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -44,6 +44,8 @@ export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
44
44
  * the open stack entries needed to name the bottom layer. Not part of BatchPr.
45
45
  */
46
46
  export const UPPER_LAYER_CONFLICT_TARGET_QUERY = gql("upper-layer-conflict-target.gql");
47
+ /** Merged PRs whose head may be the stale base of a non-stack conflict. */
48
+ export const MERGED_BASE_PULL_REQUESTS_QUERY = gql("merged-base-pull-requests.gql");
47
49
  /** PR head fields plus a single review thread for `commit-suggestion`. */
48
50
  export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
49
51
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -15,17 +15,35 @@ type PushCheckNode = {
15
15
  * build a one-parent commit on the base, so parent identity cannot show a
16
16
  * later push. Missing parents are unverifiable: GitHub keeps returning the
17
17
  * latest removal after the synthetic commit is gone. A one-parent removal is
18
- * stale once this head reached the PR after the removal. That time is the
19
- * earliest pull_request check on the head, with the head's committer time as
20
- * the fallback when no check time is available.
18
+ * stale once this head reached the PR after the removal (see `headArrivalUnix`).
19
+ * A force-push after the removal makes any removal stale, even one whose
20
+ * parents list the head: that SHA was pushed back for a new attempt.
21
21
  */
22
- export declare function queueRemovalAppliesToHead(input: {
22
+ export declare function queueRemovalAppliesToHead(input: HeadTimes & {
23
23
  parentOids: readonly string[] | null | undefined;
24
24
  headOid: string;
25
- headCommittedAtUnix?: number;
26
- headPushedAtUnix?: number;
27
25
  removedAtUnix?: number;
28
26
  }): boolean;
27
+ type HeadTimes = {
28
+ headCommittedAtUnix?: number | null;
29
+ headPushedAtUnix?: number;
30
+ headForcePushedAtUnix?: number;
31
+ };
32
+ /**
33
+ * When the current head reached the PR. The earliest pull_request check on the
34
+ * head is its push time, but it can come from an earlier tenure of a commit
35
+ * that was later force-pushed back, so the latest force-push wins when it is
36
+ * later. Without a check time, the committer time is the fallback, again
37
+ * superseded by a later force-push. A committer clock that runs ahead only
38
+ * matters in that fallback.
39
+ */
40
+ export declare function headArrivalUnix(input: HeadTimes): number | undefined;
41
+ /** Latest head-ref force-push time from a `headRefForcePushes` timeline field. */
42
+ export declare function forcePushUnix(field: {
43
+ nodes: Array<{
44
+ createdAt: string;
45
+ }>;
46
+ } | null | undefined): number | undefined;
29
47
  /** Earliest pull_request check-suite time on the head commit, when one exists. */
30
48
  export declare function headPushUnixFromCheckNodes(nodes: readonly (PushCheckNode | null)[] | null | undefined): number | undefined;
31
49
  export {};
@@ -7,28 +7,50 @@ const PULL_REQUEST_EVENTS = new Set(["pull_request", "pull_request_target"]);
7
7
  * build a one-parent commit on the base, so parent identity cannot show a
8
8
  * later push. Missing parents are unverifiable: GitHub keeps returning the
9
9
  * latest removal after the synthetic commit is gone. A one-parent removal is
10
- * stale once this head reached the PR after the removal. That time is the
11
- * earliest pull_request check on the head, with the head's committer time as
12
- * the fallback when no check time is available.
10
+ * stale once this head reached the PR after the removal (see `headArrivalUnix`).
11
+ * A force-push after the removal makes any removal stale, even one whose
12
+ * parents list the head: that SHA was pushed back for a new attempt.
13
13
  */
14
14
  export function queueRemovalAppliesToHead(input) {
15
15
  const parents = input.parentOids ?? [];
16
16
  if (parents.length === 0)
17
17
  return false;
18
+ const removedAt = positive(input.removedAtUnix);
19
+ const forcePushedAt = positive(input.headForcePushedAtUnix);
20
+ // The same SHA force-pushed back after the removal starts a new tenure.
21
+ if (removedAt !== undefined && forcePushedAt !== undefined && forcePushedAt > removedAt)
22
+ return false;
18
23
  if (parents.includes(input.headOid))
19
24
  return true;
20
25
  if (parents.length > 1)
21
26
  return false;
22
- const removedAt = input.removedAtUnix;
23
- if (removedAt === undefined || removedAt <= 0)
27
+ if (removedAt === undefined)
24
28
  return true;
25
- const pushedAt = input.headPushedAtUnix;
26
- if (pushedAt !== undefined && pushedAt > 0)
27
- return pushedAt <= removedAt;
28
- const committedAt = input.headCommittedAtUnix;
29
- if (committedAt !== undefined && committedAt > removedAt)
30
- return false;
31
- return true;
29
+ const arrivedAt = headArrivalUnix(input);
30
+ return arrivedAt === undefined || arrivedAt <= removedAt;
31
+ }
32
+ /**
33
+ * When the current head reached the PR. The earliest pull_request check on the
34
+ * head is its push time, but it can come from an earlier tenure of a commit
35
+ * that was later force-pushed back, so the latest force-push wins when it is
36
+ * later. Without a check time, the committer time is the fallback, again
37
+ * superseded by a later force-push. A committer clock that runs ahead only
38
+ * matters in that fallback.
39
+ */
40
+ export function headArrivalUnix(input) {
41
+ const pushedAt = positive(input.headPushedAtUnix) ?? positive(input.headCommittedAtUnix);
42
+ const forcePushedAt = positive(input.headForcePushedAtUnix);
43
+ if (pushedAt === undefined)
44
+ return forcePushedAt;
45
+ return forcePushedAt === undefined ? pushedAt : Math.max(pushedAt, forcePushedAt);
46
+ }
47
+ function positive(value) {
48
+ return value !== undefined && value !== null && value > 0 ? value : undefined;
49
+ }
50
+ /** Latest head-ref force-push time from a `headRefForcePushes` timeline field. */
51
+ export function forcePushUnix(field) {
52
+ const raw = field?.nodes[0]?.createdAt;
53
+ return raw ? positive(parseCreatedAt(raw)) : undefined;
32
54
  }
33
55
  /** Earliest pull_request check-suite time on the head commit, when one exists. */
34
56
  export function headPushUnixFromCheckNodes(nodes) {
@@ -1,8 +1,10 @@
1
1
  /**
2
- * One close/reopen of a head that was missing required checks.
2
+ * One presented close/reopen instruction for a head that was missing required checks.
3
3
  *
4
4
  * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/ci-retrigger.json`
5
- * Reopening does not change the head SHA, so the next tick must not close the PR again.
5
+ * This records that Shepherd returned the instruction, not that the caller executed it.
6
+ * Reopening does not change the head SHA, so the next presented tick must not offer it again.
7
+ * Internal debounce preview ticks do not write this marker.
6
8
  */
7
9
  interface Retrigger {
8
10
  headSha: string;
@@ -14,12 +16,12 @@ export declare function readCiRetrigger(key: {
14
16
  repo: string;
15
17
  pr: number;
16
18
  }): Promise<Retrigger | undefined>;
17
- /** Remember that this head was already closed and reopened for these contexts. */
19
+ /** Remember that the one close/reopen instruction was returned for this head and context set. */
18
20
  export declare function writeCiRetrigger(key: {
19
21
  owner: string;
20
22
  repo: string;
21
23
  pr: number;
22
24
  }, record: Retrigger): Promise<void>;
23
- /** True when this head was already retriggered for the same required contexts. */
25
+ /** True when the one close/reopen instruction was already returned for this head and contexts. */
24
26
  export declare function sameCiRetrigger(record: Retrigger | undefined, headSha: string, contexts: readonly string[]): boolean;
25
27
  export {};
@@ -1,8 +1,10 @@
1
1
  /**
2
- * One close/reopen of a head that was missing required checks.
2
+ * One presented close/reopen instruction for a head that was missing required checks.
3
3
  *
4
4
  * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/ci-retrigger.json`
5
- * Reopening does not change the head SHA, so the next tick must not close the PR again.
5
+ * This records that Shepherd returned the instruction, not that the caller executed it.
6
+ * Reopening does not change the head SHA, so the next presented tick must not offer it again.
7
+ * Internal debounce preview ticks do not write this marker.
6
8
  */
7
9
  import { mkdir, readFile, writeFile } from "node:fs/promises";
8
10
  import { dirname } from "node:path";
@@ -11,7 +13,7 @@ import { resolvePrStatePath } from "./base.mjs";
11
13
  export async function readCiRetrigger(key) {
12
14
  return readRetrigger(resolvePrStatePath(key, "ci-retrigger.json"));
13
15
  }
14
- /** Remember that this head was already closed and reopened for these contexts. */
16
+ /** Remember that the one close/reopen instruction was returned for this head and context set. */
15
17
  export async function writeCiRetrigger(key, record) {
16
18
  const path = resolvePrStatePath(key, "ci-retrigger.json");
17
19
  await mkdir(dirname(path), { recursive: true });
@@ -21,7 +23,7 @@ export async function writeCiRetrigger(key, record) {
21
23
  };
22
24
  await writeFile(path, JSON.stringify(next));
23
25
  }
24
- /** True when this head was already retriggered for the same required contexts. */
26
+ /** True when the one close/reopen instruction was already returned for this head and contexts. */
25
27
  export function sameCiRetrigger(record, headSha, contexts) {
26
28
  if (!record || record.headSha !== headSha)
27
29
  return false;
@@ -9,7 +9,11 @@ type StateKey = {
9
9
  repo: string;
10
10
  pr: number;
11
11
  };
12
- /** The only removal reasons that indicate a CI-driven queue ejection. */
12
+ /**
13
+ * The only removal reason that indicates a CI-driven queue ejection. GitHub sends lowercase raw
14
+ * strings; observed values also include `merged`, `merge_conflict`, `invalid_merge_commit`, and
15
+ * `stack_invalidated`, none of which a requeue can recover.
16
+ */
13
17
  export declare function isCiQueueRemovalReason(reason: string | null | undefined): boolean;
14
18
  /** Missing, unreadable, or malformed acknowledgment state is treated as absent. */
15
19
  export declare function readQueueRemovalAcknowledgment(key: StateKey): Promise<QueueRemovalAcknowledgment | null>;
@@ -4,9 +4,13 @@ import { randomUUID } from "node:crypto";
4
4
  import { dirname } from "node:path";
5
5
  import { resolvePrStatePath } from "./base.mjs";
6
6
  const FILE = "queue-removal-ack.json";
7
- /** The only removal reasons that indicate a CI-driven queue ejection. */
7
+ /**
8
+ * The only removal reason that indicates a CI-driven queue ejection. GitHub sends lowercase raw
9
+ * strings; observed values also include `merged`, `merge_conflict`, `invalid_merge_commit`, and
10
+ * `stack_invalidated`, none of which a requeue can recover.
11
+ */
8
12
  export function isCiQueueRemovalReason(reason) {
9
- return reason === "CI_FAILURE" || reason === "MERGE_QUEUE_POLICY_CHECK_FAILURE";
13
+ return reason === "failed_checks";
10
14
  }
11
15
  /** Missing, unreadable, or malformed acknowledgment state is treated as absent. */
12
16
  export async function readQueueRemovalAcknowledgment(key) {
@@ -6,6 +6,19 @@ export type CheckStatus = "COMPLETED" | "IN_PROGRESS" | "PENDING" | "QUEUED" | "
6
6
  export type MergeableState = "CONFLICTING" | "MERGEABLE" | "UNKNOWN";
7
7
  export type MergeStateStatus = "BEHIND" | "BLOCKED" | "CLEAN" | "DIRTY" | "DRAFT" | "HAS_HOOKS" | "UNKNOWN" | "UNSTABLE";
8
8
  export type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
9
+ /** Raw GitHub fields for a merged PR whose head is this PR's current base. */
10
+ export interface MergedBasePullRequest {
11
+ number: number;
12
+ url: string;
13
+ state: "MERGED";
14
+ headRefName: string;
15
+ headRefOid: string;
16
+ baseRefName: string;
17
+ mergedAt: string;
18
+ headRepository: {
19
+ nameWithOwner: string;
20
+ };
21
+ }
9
22
  export type AuthorType = "User" | "Bot" | "Unknown";
10
23
  type RepositoryPermission = "NONE" | "READ" | "TRIAGE" | "WRITE" | "MAINTAIN" | "ADMIN";
11
24
  /** Raw GitHub viewer fields used to gate remote actions. */
@@ -146,6 +159,10 @@ export interface BatchPrData extends BatchPrMergeFields {
146
159
  * that signal is unavailable; queue-removal freshness then uses committer time.
147
160
  */
148
161
  headPushedAtUnix?: number;
162
+ /** Unix times of the last 10 merge-queue removals, oldest first. */
163
+ mergeQueueRemovalTimesUnix?: number[];
164
+ /** Unix time of the latest head-ref force-push, when GitHub reports one. */
165
+ headForcePushedAtUnix?: number;
149
166
  headRefName: string;
150
167
  /** `"owner/name"` of the head repository; null when the fork has been deleted. */
151
168
  headRepoWithOwner: string | null;
@@ -1,6 +1,6 @@
1
1
  import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment, RuleAutoResolveReport } from "./report.mts";
2
2
  import type { ActiveCheck, PrActivitySummary } from "./activity.mts";
3
- import type { BranchProtection, MergeStateStatus, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
3
+ import type { BranchProtection, MergeStateStatus, MergedBasePullRequest, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
4
4
  import type { MergeRequirements, StackDraftHold } from "./merge-requirements.mts";
5
5
  import type { EscalateDetails } from "./escalate.mts";
6
6
  import type { MergeCommandPlan } from "./merge-action.mts";
@@ -34,6 +34,8 @@ export interface IterateResultBase {
34
34
  * PR base, so the dirty state is against the stack trunk rather than that base.
35
35
  */
36
36
  stackTrunkConflict?: string;
37
+ /** Merged PRs whose exact head commit and repository match this conflicting PR's base. */
38
+ mergedBasePullRequests?: MergedBasePullRequest[];
37
39
  /** Null when no classic protection rule exists or the base ref is unavailable. */
38
40
  branchProtection: BranchProtection | null;
39
41
  mergeRequirements?: MergeRequirements;
@@ -10,6 +10,8 @@ export interface MergeQueueReport {
10
10
  checksIncomplete?: true;
11
11
  /** The current PR head is not a parent of the removed synthetic queue commit. */
12
12
  headUpdatedAfterRemoval?: true;
13
+ /** Queue removals since the current head reached the PR; emitted only when above 1. */
14
+ removalsOnHead?: number;
13
15
  /** The caller acknowledged this exact native-stack removal on the current head. */
14
16
  removalAcknowledged?: true;
15
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.3",
3
+ "version": "0.56.5",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.3",
3
+ "version": "0.56.5",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.56.3", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.56.5", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.56.3", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.56.5", "pr-shepherd-mcp"]
5
5
  }
6
6
  }
@@ -73,3 +73,4 @@ When a step says `Playbook: "<name>"`, read that file once and apply it before t
73
73
  - [Shepherd Journal](references/journal.md)
74
74
  - [Branch update](references/branch-update.md)
75
75
  - [Stack merge](references/stack-merge.md)
76
+ - [Merge queue ejection](references/merge-queue-ejection.md)
@@ -6,7 +6,7 @@ Apply when a step says `Playbook: "CI failure triage"`. For a GitHub Actions row
6
6
  - `[rerun authorized]` plus a `rerun:` command means the viewer can rerun Actions (WRITE+) and this is the original attempt. Shepherd checked `repositoryPermission` and `run_attempt`.
7
7
  - Run that printed command at most once. An `[attempt: N]` check never gets another rerun. A log excerpt on a later attempt is still investigation work. A later attempt with no usable evidence escalates when nothing else remains.
8
8
  - A run in progress, `[conclusion: ACTION_REQUIRED]`, a check whose run id is not a GitHub Actions workflow, or a run with no attempt metadata never gets `[rerun authorized]`.
9
- - A check with `scope: merge_group` never gets a rerun command. Rerunning cannot restore a removed queue entry and overwrites the failure evidence. If the failure belongs to this PR, fix the PR head. If it does not, follow the printed requeue instruction when a plan is present. Without `--merge`, report the failure without enqueueing. For native stacks, child sessions omit `--merge` and may still record the printed local acknowledgment after inspecting logs or an external provider URL. Complete fresh one-PR READY validation before returning to the aggregate selector with its original options; never enqueue a stack layer directly.
9
+ - A check with `scope: merge_group` never gets a rerun command. Rerunning cannot restore a removed queue entry and overwrites the failure evidence. If the failure belongs to this PR, fix the PR head. If it does not and an ejection step is printed, apply the Merge queue ejection playbook it names. Without an ejection step the entry is still queued: make no queue mutation and iterate until GitHub reports the removal.
10
10
  - Do not invent a handoff from `[FIX_CODE]`. Shepherd returns `[ESCALATE]` when no autonomous follow-up remains.
11
11
  - Several bullets can share one run id (matrix jobs). The `rerun:` command is printed once, on the first bullet. Run it once.
12
12
 
@@ -0,0 +1,17 @@
1
+ # Merge queue ejection
2
+
3
+ Apply when a step says `Playbook: "Merge queue ejection"`. The `**queue removal**` header names the queue commit. That commit combined the PR head with the base and any entries queued ahead of it, so a failure there may come from this PR, the base, another entry, or a flake. Do not requeue without triage. After triage, continue with the remaining numbered steps; iterate only at the final step.
4
+
5
+ 1. Update the PR head from the latest base following the repository's branch-update convention. On a native stack, use the printed stack update route instead. Reproduce the failing step on the updated head.
6
+ 2. If the failure belongs to this PR, fix it. The printed push step commits and pushes the fix.
7
+ 3. If the failure does not reproduce and the update changed the head, the printed push step pushes it (`gh stack push` on a native stack). Never run a printed `requeue:` or `acknowledge queue removal:` command after a push. Shepherd prints a fresh queue command once the new head is READY.
8
+ 4. If the failure comes from the base itself, do not requeue or acknowledge it. If a Shepherd Journal step is printed, record the finding with it. Make no change. The unchanged failure escalates through the stall timeout.
9
+ 5. If the removal reason suggests a person dequeued the PR, skip the step 1 update unless a conflict step requires it. Fix a failure that belongs to this PR; otherwise make no change. Never enqueue.
10
+ 6. If the step says no queue command was printed, never enqueue. A failure that does not reproduce follows step 4. Shepherd prints no queue command once the same head was already removed before (`removals on this head` in the header), so a flaky failure is not retried indefinitely.
11
+ 7. Otherwise, run the printed `requeue:` or `acknowledge queue removal:` command exactly as printed, and only if all of these hold:
12
+ - the head did not change;
13
+ - no code changed;
14
+ - no other blocker remains;
15
+ - the logs or the check's details page show a transient failure, or a failure caused by another entry in the same queue group.
16
+ 8. If gh reports auto-merge is disabled instead of adding the PR to the queue, run the `requeue API fallback:` command. Both requeue commands require the observed PR head SHA. If the head changed, iterate for a fresh command.
17
+ 9. After `acknowledge queue removal:`, finish this one-PR session so it validates current source CI and records its READY receipt. Then return to the aggregate `--stack` selector with its original options. In merge mode it verifies lower-layer readiness before merging. Never enqueue or merge a stack layer directly.