pr-shepherd 0.56.2 → 0.56.4

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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/checks/classify.d.mts +5 -6
  4. package/bin/checks/classify.mjs +5 -7
  5. package/bin/checks/superseded.d.mts +4 -17
  6. package/bin/checks/superseded.mjs +67 -43
  7. package/bin/cli/iterate-merge-formatter.mjs +2 -0
  8. package/bin/commands/apply-queue-removal.mjs +3 -4
  9. package/bin/commands/check.mjs +15 -11
  10. package/bin/commands/iterate/fix-code.mjs +14 -17
  11. package/bin/commands/iterate/merge.mjs +3 -0
  12. package/bin/commands/iterate/native-stack-rebase.d.mts +5 -2
  13. package/bin/commands/iterate/native-stack-rebase.mjs +11 -2
  14. package/bin/commands/iterate/queue-recovery-instructions.d.mts +8 -0
  15. package/bin/commands/iterate/queue-recovery-instructions.mjs +38 -0
  16. package/bin/commands/iterate/render.d.mts +3 -1
  17. package/bin/commands/iterate/render.mjs +13 -16
  18. package/bin/commands/iterate/stall.mjs +2 -0
  19. package/bin/github/batch-parsers.mjs +6 -0
  20. package/bin/github/batch-raw-rules.d.mts +12 -0
  21. package/bin/github/gql/batch-pr.gql +14 -0
  22. package/bin/github/gql/poll-summary-check-contexts.gql +2 -0
  23. package/bin/github/gql/poll-summary-fragment.gql +7 -0
  24. package/bin/github/merge-queue-checks.mjs +2 -1
  25. package/bin/github/poll-summary-checks.mjs +22 -18
  26. package/bin/github/poll-summary-queue-removal.mjs +2 -1
  27. package/bin/github/poll-summary-raw.d.mts +8 -0
  28. package/bin/github/queue-removal-freshness.d.mts +24 -6
  29. package/bin/github/queue-removal-freshness.mjs +34 -12
  30. package/bin/state/queue-removal-ack.d.mts +5 -1
  31. package/bin/state/queue-removal-ack.mjs +6 -2
  32. package/bin/types/github.d.mts +4 -0
  33. package/bin/types/merge-queue.d.mts +2 -0
  34. package/package.json +2 -2
  35. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  36. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  37. package/plugins/pr-shepherd/.mcp.json +1 -1
  38. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -0
  39. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -1
  40. 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.2",
4
+ "version": "0.56.4",
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
 
@@ -7,11 +7,10 @@
7
7
  * to PR readiness.
8
8
  * 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
9
9
  * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
- * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer run of
11
- * the same workflow exists on the same commit — this is GitHub's concurrency-group
12
- * eviction behavior, not a real failure. GitHub branch protection itself resolves
13
- * required status checks by latest-run-per-name and merges past these; mirroring
14
- * that here keeps shepherd's verdict aligned with what GitHub will actually allow.
10
+ * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
11
+ * run of the same workflow exists on the same commit and event, or when an
12
+ * exact matching check from a lower-ID run started and succeeded later.
13
+ * GitHub can start jobs out of workflow-run creation order.
15
14
  */
16
15
  import type { CheckRun, ClassifiedCheck } from "../types.mts";
17
16
  /**
@@ -36,7 +35,7 @@ export interface CiVerdict {
36
35
  filteredNames: string[];
37
36
  /** Names of checks suppressed by the user's ignoreChecks config. */
38
37
  ignoredNames: string[];
39
- /** Names of CANCELLED checks superseded by a newer run of the same workflow (concurrency-group eviction). */
38
+ /** Names of CANCELLED checks covered by another run of the same workflow and event. */
40
39
  supersededNames: string[];
41
40
  }
42
41
  /** Compute a high-level CI verdict from a list of classified checks. */
@@ -7,11 +7,10 @@
7
7
  * to PR readiness.
8
8
  * 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
9
9
  * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
- * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer run of
11
- * the same workflow exists on the same commit — this is GitHub's concurrency-group
12
- * eviction behavior, not a real failure. GitHub branch protection itself resolves
13
- * required status checks by latest-run-per-name and merges past these; mirroring
14
- * that here keeps shepherd's verdict aligned with what GitHub will actually allow.
10
+ * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
11
+ * run of the same workflow exists on the same commit and event, or when an
12
+ * exact matching check from a lower-ID run started and succeeded later.
13
+ * GitHub can start jobs out of workflow-run creation order.
15
14
  */
16
15
  import { loadConfig } from "../config/load.mjs";
17
16
  import { buildSupersededIndices } from "./superseded.mjs";
@@ -37,8 +36,7 @@ export function classifyChecks(checks, opts = {}) {
37
36
  return { ...c, category: "ignored" };
38
37
  }
39
38
  const classified = classify(c, relevantEvents);
40
- // Only ever override a "failing" verdict (i.e. conclusion === CANCELLED, guaranteed by
41
- // buildSupersededIndices below) — never touch filtered/skipped/passed classifications.
39
+ // Only override a failing CANCELLED check, never filtered/skipped/passed classifications.
42
40
  if (classified.category === "failing" && supersededIndices.has(index)) {
43
41
  return { ...classified, category: "superseded" };
44
42
  }
@@ -1,21 +1,8 @@
1
- /**
2
- * Detects check runs that are `CANCELLED` because a newer run of the *same workflow*
3
- * superseded them on the same commit (concurrency-group eviction), rather than a genuine
4
- * cancellation. Split out of classify.mts to stay under the file-length cap.
5
- */
1
+ /** Identify cancelled checks covered by another run on the same commit and event. */
6
2
  import type { CheckRun } from "../types.mts";
7
3
  /**
8
- * Grouping key is `workflowId ?? workflowName` — the numeric GitHub Actions workflow database
9
- * ID when available, falling back to the display name. Checks with neither a workflow identity
10
- * nor a numeric `runId` (status contexts, startup-failure synthetics) never participate: they
11
- * can neither be marked superseded nor count as evidence of a newer run.
12
- *
13
- * A check is superseded iff its own conclusion is `CANCELLED` and some other check sharing its
14
- * workflow key has a strictly greater `runId`. The newest run for a workflow is therefore never
15
- * superseded, even if it is itself cancelled — that case stays "failing" so the agent can decide
16
- * whether to rerun it.
17
- *
18
- * @returns Indices into `checks` (not object identities, since check-run objects are not
19
- * deduplicated by reference elsewhere) that should be reclassified as "superseded".
4
+ * A later-created run supersedes an older cancellation as before. GitHub can start
5
+ * check jobs out of run-ID order, so a lower-ID run can also cover a cancellation
6
+ * when its matching successful job actually started and completed later.
20
7
  */
21
8
  export declare function buildSupersededIndices(checks: CheckRun[]): Set<number>;
@@ -1,59 +1,83 @@
1
- /**
2
- * Detects check runs that are `CANCELLED` because a newer run of the *same workflow*
3
- * superseded them on the same commit (concurrency-group eviction), rather than a genuine
4
- * cancellation. Split out of classify.mts to stay under the file-length cap.
5
- */
6
- /** Grouping key for a check's workflow: numeric `workflowId`, falling back to `workflowName`. */
1
+ /** Identify cancelled checks covered by another run on the same commit and event. */
2
+ /** Prefer the stable workflow ID; retain the name fallback for the older-run rule. */
7
3
  function workflowKeyOf(check) {
8
- return check.workflowId ?? check.workflowName;
4
+ if (check.workflowId)
5
+ return `id:${check.workflowId}`;
6
+ return check.workflowName ? `name:${check.workflowName}` : undefined;
7
+ }
8
+ function groupKeyOf(check) {
9
+ const workflow = workflowKeyOf(check);
10
+ if (workflow === undefined)
11
+ return undefined;
12
+ return JSON.stringify([workflow, check.event, check.scope ?? null, check.commitOid ?? null]);
13
+ }
14
+ function numericRunId(check) {
15
+ if (check.runId === null || !/^[1-9]\d*$/.test(check.runId))
16
+ return undefined;
17
+ const id = Number(check.runId);
18
+ return Number.isSafeInteger(id) ? id : undefined;
19
+ }
20
+ function validTimes(check) {
21
+ const { startedAtUnix: start, completedAtUnix: completion } = check;
22
+ return (typeof start === "number" &&
23
+ Number.isFinite(start) &&
24
+ start > 0 &&
25
+ typeof completion === "number" &&
26
+ Number.isFinite(completion) &&
27
+ completion > 0 &&
28
+ completion >= start);
9
29
  }
10
30
  /**
11
- * Grouping key is `workflowId ?? workflowName` — the numeric GitHub Actions workflow database
12
- * ID when available, falling back to the display name. Checks with neither a workflow identity
13
- * nor a numeric `runId` (status contexts, startup-failure synthetics) never participate: they
14
- * can neither be marked superseded nor count as evidence of a newer run.
15
- *
16
- * A check is superseded iff its own conclusion is `CANCELLED` and some other check sharing its
17
- * workflow key has a strictly greater `runId`. The newest run for a workflow is therefore never
18
- * superseded, even if it is itself cancelled — that case stays "failing" so the agent can decide
19
- * whether to rerun it.
20
- *
21
- * @returns Indices into `checks` (not object identities, since check-run objects are not
22
- * deduplicated by reference elsewhere) that should be reclassified as "superseded".
31
+ * A later-created run supersedes an older cancellation as before. GitHub can start
32
+ * check jobs out of run-ID order, so a lower-ID run can also cover a cancellation
33
+ * when its matching successful job actually started and completed later.
23
34
  */
24
35
  export function buildSupersededIndices(checks) {
25
- const runIdByIndex = new Map();
26
- const maxRunIdByWorkflow = new Map();
36
+ const maxRunIdByGroup = new Map();
37
+ const runIds = checks.map(numericRunId);
27
38
  checks.forEach((check, index) => {
28
- const workflowKey = workflowKeyOf(check);
29
- if (workflowKey === undefined || check.runId === null)
30
- return;
31
- const runIdNum = Number(check.runId);
32
- if (!Number.isFinite(runIdNum))
39
+ const group = groupKeyOf(check);
40
+ const runId = runIds[index];
41
+ if (group === undefined || runId === undefined)
33
42
  return;
34
- runIdByIndex.set(index, runIdNum);
35
- const currentMax = maxRunIdByWorkflow.get(workflowKey);
36
- if (currentMax === undefined || runIdNum > currentMax) {
37
- maxRunIdByWorkflow.set(workflowKey, runIdNum);
38
- }
43
+ const previous = maxRunIdByGroup.get(group);
44
+ if (previous === undefined || runId > previous)
45
+ maxRunIdByGroup.set(group, runId);
39
46
  });
40
47
  const superseded = new Set();
41
- checks.forEach((check, index) => {
42
- if (check.conclusion !== "CANCELLED")
48
+ checks.forEach((cancelled, index) => {
49
+ if (cancelled.conclusion !== "CANCELLED")
43
50
  return;
44
- const runIdNum = runIdByIndex.get(index);
45
- if (runIdNum === undefined)
51
+ const group = groupKeyOf(cancelled);
52
+ const runId = runIds[index];
53
+ if (group === undefined || runId === undefined)
46
54
  return;
47
- // workflowKeyOf(check) is guaranteed defined here, with a corresponding entry in
48
- // maxRunIdByWorkflow: runIdByIndex is only ever populated in the loop above alongside a
49
- // maxRunIdByWorkflow entry for that same workflow key (at minimum, this check's own
50
- // runIdNum) — the two maps are always updated together for a given index. A defensive
51
- // undefined-check here would therefore guard a branch no input can ever exercise, which
52
- // would silently fail this repo's 100%-coverage requirement instead of catching a real bug.
53
- const maxRunId = maxRunIdByWorkflow.get(workflowKeyOf(check));
54
- if (maxRunId > runIdNum) {
55
+ if (maxRunIdByGroup.get(group) > runId) {
55
56
  superseded.add(index);
57
+ return;
56
58
  }
59
+ if (cancelled.status !== "COMPLETED" ||
60
+ !cancelled.workflowId ||
61
+ cancelled.event === null ||
62
+ !validTimes(cancelled))
63
+ return;
64
+ const covered = checks.some((success, candidateIndex) => {
65
+ const candidateRunId = runIds[candidateIndex];
66
+ return (candidateRunId !== undefined &&
67
+ candidateRunId < runId &&
68
+ success.status === "COMPLETED" &&
69
+ success.conclusion === "SUCCESS" &&
70
+ success.workflowId === cancelled.workflowId &&
71
+ success.event === cancelled.event &&
72
+ success.scope === cancelled.scope &&
73
+ success.commitOid === cancelled.commitOid &&
74
+ success.name === cancelled.name &&
75
+ validTimes(success) &&
76
+ success.startedAtUnix > cancelled.startedAtUnix &&
77
+ success.completedAtUnix > cancelled.completedAtUnix);
78
+ });
79
+ if (covered)
80
+ superseded.add(index);
57
81
  });
58
82
  return superseded;
59
83
  }
@@ -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,6 +8,7 @@ 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";
@@ -301,25 +302,21 @@ export async function handleFixCode(ctx) {
301
302
  trunk: stack.baseRefName,
302
303
  })
303
304
  : 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
305
  const requeue = buildRemovedQueueRecovery(report, failingAgentChecks, opts.merge);
310
306
  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
- }
307
+ const queueEjection = !currentEjectionCommit(report, failingAgentChecks)
308
+ ? undefined
309
+ : requeue
310
+ ? "requeue"
311
+ : queueRemovalAcknowledgment
312
+ ? "acknowledge"
313
+ : "none";
314
+ // Conflicts, a behind branch whose rerun already failed, and a queue ejection that may be
315
+ // reproduced on the latest base all print the branch update route.
316
+ const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0) || queueEjection
317
+ ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
318
+ : undefined;
319
+ 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
320
  if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
324
321
  const completion = instructions.pop();
325
322
  instructions.push(buildReleasedBlockerInstruction(prNumber));
@@ -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) ||
@@ -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,
@@ -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
@@ -12,6 +12,8 @@ fragment PollSummaryCheckContexts on StatusCheckRollupContextConnection {
12
12
  status
13
13
  conclusion
14
14
  detailsUrl
15
+ startedAt
16
+ completedAt
15
17
  checkSuite {
16
18
  createdAt
17
19
  workflowRun {
@@ -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,10 +1,9 @@
1
1
  import { classifyChecks } from "../checks/classify.mjs";
2
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
2
3
  export function summarizePollSummaryChecks(raw) {
3
4
  const rollups = summaryRollups(raw);
4
5
  const counts = {};
5
- for (const check of classifyChecks(checkRuns(rollups), {
6
- additionalRelevantEvents: ["merge_group"],
7
- })) {
6
+ for (const check of classifiedSummaryChecks(raw)) {
8
7
  const key = {
9
8
  passed: "passing",
10
9
  failing: "failing",
@@ -17,33 +16,36 @@ export function summarizePollSummaryChecks(raw) {
17
16
  counts[key] = (counts[key] ?? 0) + 1;
18
17
  }
19
18
  const summary = counts;
20
- if (rollups.some((rollup) => rollup.contexts.pageInfo.hasPreviousPage)) {
19
+ if (rollups.some(({ rollup }) => rollup.contexts.pageInfo.hasPreviousPage)) {
21
20
  summary.incomplete = true;
22
21
  }
23
22
  return summary;
24
23
  }
25
24
  /** Names of failing checks in the loaded summary rollup. */
26
25
  export function failingSummaryCheckNames(raw) {
27
- return classifyChecks(checkRuns(summaryRollups(raw)), {
28
- additionalRelevantEvents: ["merge_group"],
29
- })
26
+ return classifiedSummaryChecks(raw)
30
27
  .filter((check) => check.category === "failing")
31
28
  .map((check) => check.name);
32
29
  }
33
30
  function summaryRollups(raw) {
34
31
  return [
35
- raw.commits.nodes[0]?.commit.statusCheckRollup,
36
- raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
37
- ].filter((rollup) => rollup !== null && rollup !== undefined);
32
+ { rollup: raw.commits.nodes[0]?.commit.statusCheckRollup, scope: undefined },
33
+ {
34
+ rollup: raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
35
+ scope: "merge_group",
36
+ },
37
+ ].flatMap(({ rollup, scope }) => (rollup ? [{ rollup, scope }] : []));
38
38
  }
39
- function checkRuns(rollups) {
40
- return rollups.flatMap((rollup, rollupIndex) => rollup.contexts.nodes.map((context) => {
39
+ function classifiedSummaryChecks(raw) {
40
+ // A successful check on the queue commit cannot cover a PR-head cancellation, or vice versa.
41
+ return summaryRollups(raw).flatMap(({ rollup, scope }) => classifyChecks(checkRuns(rollup, scope), scope === "merge_group" ? { additionalRelevantEvents: ["merge_group"] } : {}));
42
+ }
43
+ function checkRuns(rollup, scope) {
44
+ return rollup.contexts.nodes.map((context) => {
41
45
  if (context.__typename === "StatusContext") {
42
46
  return {
43
47
  name: context.context,
44
- status: context.state === "PENDING" || context.state === "EXPECTED"
45
- ? "IN_PROGRESS"
46
- : "COMPLETED",
48
+ status: context.state === "PENDING" || context.state === "EXPECTED" ? "IN_PROGRESS" : "COMPLETED",
47
49
  conclusion: context.state === "SUCCESS"
48
50
  ? "SUCCESS"
49
51
  : context.state === "FAILURE" || context.state === "ERROR"
@@ -53,7 +55,7 @@ function checkRuns(rollups) {
53
55
  detailsUrl: "",
54
56
  event: null,
55
57
  runId: null,
56
- ...(rollupIndex === 1 && { scope: "merge_group" }),
58
+ ...(scope && { scope }),
57
59
  };
58
60
  }
59
61
  const run = context.checkSuite?.workflowRun;
@@ -70,7 +72,9 @@ function checkRuns(rollups) {
70
72
  ...(run?.workflow?.databaseId != null && {
71
73
  workflowId: String(run.workflow.databaseId),
72
74
  }),
73
- ...(rollupIndex === 1 && { scope: "merge_group" }),
75
+ ...(context.startedAt && { startedAtUnix: parseCreatedAt(context.startedAt) }),
76
+ ...(context.completedAt && { completedAtUnix: parseCreatedAt(context.completedAt) }),
77
+ ...(scope && { scope }),
74
78
  };
75
- }));
79
+ });
76
80
  }
@@ -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;
@@ -26,6 +26,8 @@ type RawCheckContext = {
26
26
  status: string;
27
27
  conclusion: string | null;
28
28
  detailsUrl?: string;
29
+ startedAt?: string | null;
30
+ completedAt?: string | null;
29
31
  annotations?: {
30
32
  totalCount: number;
31
33
  };
@@ -136,6 +138,12 @@ export interface RawSummaryPr {
136
138
  mergeQueueEntry: {
137
139
  headCommit: RawSummaryCommit | null;
138
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;
139
147
  stack: {
140
148
  number: number;
141
149
  size: number;
@@ -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) {
@@ -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) {
@@ -146,6 +146,10 @@ export interface BatchPrData extends BatchPrMergeFields {
146
146
  * that signal is unavailable; queue-removal freshness then uses committer time.
147
147
  */
148
148
  headPushedAtUnix?: number;
149
+ /** Unix times of the last 10 merge-queue removals, oldest first. */
150
+ mergeQueueRemovalTimesUnix?: number[];
151
+ /** Unix time of the latest head-ref force-push, when GitHub reports one. */
152
+ headForcePushedAtUnix?: number;
149
153
  headRefName: string;
150
154
  /** `"owner/name"` of the head repository; null when the fork has been deleted. */
151
155
  headRepoWithOwner: string | null;
@@ -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.2",
3
+ "version": "0.56.4",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -89,7 +89,7 @@
89
89
  "husky": "^9.1.7",
90
90
  "knip": "^6.14.1",
91
91
  "marked": "^18.0.11",
92
- "oxfmt": "^0.68.0",
92
+ "oxfmt": "^0.70.0",
93
93
  "oxlint": "^1.60.0",
94
94
  "typescript": "^7.0.2",
95
95
  "vitest": "^5.0.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.2",
3
+ "version": "0.56.4",
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.2", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.56.4", "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.2", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.56.4", "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.