pr-shepherd 0.52.1 → 0.53.1

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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -13
  3. package/bin/cli/api-usage-formatter.mjs +17 -3
  4. package/bin/cli/iterate-instructions.mjs +2 -21
  5. package/bin/cli/poll-summary-formatter.mjs +1 -2
  6. package/bin/commands/iterate/api-usage.mjs +4 -3
  7. package/bin/commands/iterate/index.mjs +3 -12
  8. package/bin/commands/iterate/parent-first.d.mts +6 -23
  9. package/bin/commands/iterate/parent-first.mjs +7 -64
  10. package/bin/commands/poll-quota.d.mts +18 -6
  11. package/bin/commands/poll-quota.mjs +55 -17
  12. package/bin/commands/poll-summary-instructions.mjs +26 -41
  13. package/bin/commands/poll-summary.mjs +6 -6
  14. package/bin/commands/poll.mjs +8 -10
  15. package/bin/commands/quota-selection.d.mts +7 -0
  16. package/bin/commands/quota-selection.mjs +20 -0
  17. package/bin/commands/stack-drain.d.mts +5 -5
  18. package/bin/commands/stack-drain.mjs +39 -30
  19. package/bin/commands/stack-layer-readiness.d.mts +2 -3
  20. package/bin/commands/stack-layer-readiness.mjs +4 -5
  21. package/bin/commands/stack-stall.mjs +0 -1
  22. package/bin/commands/stack-work.d.mts +4 -4
  23. package/bin/commands/stack-work.mjs +5 -6
  24. package/bin/github/api-telemetry-aggregate.d.mts +1 -0
  25. package/bin/github/api-telemetry-aggregate.mjs +8 -2
  26. package/bin/github/api-telemetry.d.mts +4 -0
  27. package/bin/github/api-telemetry.mjs +12 -0
  28. package/bin/github/graphql-http.mjs +6 -0
  29. package/bin/github/http-auth.d.mts +3 -0
  30. package/bin/github/http-auth.mjs +6 -0
  31. package/bin/github/http-intermediate.d.mts +1 -0
  32. package/bin/github/http-intermediate.mjs +3 -0
  33. package/bin/quota-budgets.d.mts +16 -0
  34. package/bin/quota-budgets.mjs +65 -0
  35. package/bin/quota-warning.mjs +11 -1
  36. package/bin/state/graphql-quota-policy.d.mts +7 -1
  37. package/bin/state/graphql-quota-policy.mjs +42 -15
  38. package/bin/state/graphql-quota-warnings.d.mts +2 -2
  39. package/bin/state/graphql-quota-warnings.mjs +7 -4
  40. package/bin/types/api-usage.d.mts +14 -1
  41. package/bin/types/merge-requirements.d.mts +3 -13
  42. package/bin/types/poll-summary.d.mts +0 -2
  43. package/package.json +1 -1
  44. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  45. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  46. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -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.52.1",
4
+ "version": "0.53.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -147,22 +147,24 @@ needed, every selected PR is complete, the bounded timeout expires, or `--until-
147
147
  configured GraphQL quota-warning band. Explicit PR sets give each actionable row an exact single-PR
148
148
  `pollCommand`, so independent rows can proceed before the next aggregate poll.
149
149
 
150
- Native-stack rows are ordered bottom-to-top. `--stack` never performs a mutation itself; only
151
- `--stack --merge` can emit a bottom-layer merge command for the agent. An unready layer (draft, missing a READY
152
- receipt, conflicting, failing, or stale) returns stack-level `SHEPHERD` with one-PR Shepherd instructions for
153
- the affected layers. A draft or other unready lower layer marks every higher open layer with
154
- `blockedByPr`; review and CI sessions on independent layers may proceed concurrently, but an upper
155
- draft cannot transition to ready until every lower layer has its READY receipt. With automatic
156
- mark-ready disabled, the instructions ask the agent to mark a clean, unblocked draft layer ready. A
157
- queued stack, or one whose remaining layers can only wait, returns `WAIT`; an idle `WAIT` that stays unchanged past the stall timeout returns `ESCALATE` with `stall-timeout`. A terminal READY or fully merged stack returns `CANCEL`. Closed or unverified
150
+ Native-stack rows are ordered bottom-to-top. `--stack` never performs a mutation itself. Every
151
+ layer that still has work gets its own one-PR session on the same tick, including a clean draft
152
+ whose session marks it ready. Layers do not wait for a lower layer's READY receipt, so their
153
+ ready-delays overlap. With automatic mark-ready disabled, the instructions ask the agent to mark
154
+ a clean draft ready after its probe. A queued stack, or one whose remaining layers can only wait,
155
+ returns `WAIT`; an idle `WAIT` that stays unchanged past the stall timeout returns `ESCALATE` with
156
+ `stall-timeout`. A terminal READY or fully merged stack returns `CANCEL`. Closed or unverified
158
157
  topology returns `ESCALATE` for human direction after any other shepherdable PRs are handled;
159
158
  until then, `SHEPHERD` remains the immediate action and lists the human blockers too.
160
159
 
161
- With `--stack --merge`, a READY bottom open layer that GitHub has retargeted onto the stack base
162
- returns `MERGE` with `gh stack merge <PR number> --yes --squash`, which merges or enqueues that layer
163
- alone; unready upper layers keep their one-PR sessions alongside it. After each merge, GitHub
164
- retargets the next layer, so the rerun drains the stack layer by layer until it returns `CANCEL`. API and MCP aggregate calls perform one summary tick and leave recurrence to the
165
- caller.
160
+ With `--stack --merge`, the highest open layer whose open lower layers all have current READY
161
+ receipts, and whose bottom open layer GitHub has retargeted onto the stack base, returns `MERGE`
162
+ with `gh stack merge <that PR number> --yes --squash`. That lands the named layer and every
163
+ unmerged layer below it. When the base uses a merge queue, the same command queues the prefix
164
+ together and GitHub evaluates each layer from the bottom; a failure ejects that layer and those
165
+ above it. Layers above the prefix keep their one-PR sessions. After the merge, GitHub retargets
166
+ the next layer, so the rerun continues until the stack returns `CANCEL`. API and MCP aggregate
167
+ calls perform one summary tick and leave recurrence to the caller.
166
168
 
167
169
  Polling defaults can be set under `poll` in `.pr-shepherdrc.yml`: `intervalSeconds`, `timeoutSeconds`, `debounceSeconds`, and `quietStatus`. Explicit flags override configuration, including `--no-quiet-status` when a shared config enables quiet output. Quiet status remains off by default.
168
170
 
@@ -9,7 +9,7 @@ export function formatQuotaWarning(warning) {
9
9
  if (warning === undefined)
10
10
  return null;
11
11
  const used = warning.used === undefined ? "" : ` · used ${warning.used}`;
12
- return [
12
+ const lines = [
13
13
  "## GitHub API quota warning",
14
14
  "",
15
15
  `- Resource: \`${warning.resource}\``,
@@ -18,8 +18,22 @@ export function formatQuotaWarning(warning) {
18
18
  `- Reset: ${resetTime(warning.resetAt)}`,
19
19
  `- Recommended poll interval: ${warning.pollIntervalMinutes} minutes`,
20
20
  `- Recommended bounded CLI timeout: ${warning.pollTimeoutMinutes} minutes`,
21
- "- Recommendation: keep polling pr-shepherd at the cadence above; for incidental PR operations prefer REST `gh` (`gh pr view`, `gh pr review`, `gh api`); do not substitute `gh pr checks`/`gh pr watch`",
22
- ].join("\n");
21
+ recommendation(warning),
22
+ ];
23
+ for (const budget of warning.budgets ?? []) {
24
+ const budgetUsed = budget.used === undefined ? "" : ` · used ${budget.used}`;
25
+ lines.push(`- Budget \`${budget.resource}\`: ${budget.remaining}/${budget.limit} remaining${budgetUsed} · crossed ${budget.thresholdPercent}% · resets ${resetTime(budget.resetAt)}`);
26
+ }
27
+ return lines.join("\n");
28
+ }
29
+ function recommendation(warning) {
30
+ if (warning.resource === "combined") {
31
+ return "- Recommendation: keep polling pr-shepherd at the cadence above; both GraphQL and REST core are below their warning thresholds, so do not shift work between them. Do not substitute `gh pr checks` or `gh pr watch`";
32
+ }
33
+ if (warning.resource === "core") {
34
+ return "- Recommendation: keep polling pr-shepherd at the cadence above; do not add incidental REST `gh` calls (`gh pr view`, `gh pr review`, `gh api`) while REST core is low. Do not substitute `gh pr checks` or `gh pr watch`";
35
+ }
36
+ return "- Recommendation: keep polling pr-shepherd at the cadence above; for incidental PR operations prefer REST `gh` (`gh pr view`, `gh pr review`, `gh api`); do not substitute `gh pr checks` or `gh pr watch`";
23
37
  }
24
38
  export function formatApiUsage(usage) {
25
39
  if (usage === undefined)
@@ -4,18 +4,6 @@ import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
4
4
  import { formatPrUrl } from "../pr-reference.mjs";
5
5
  import { AUTO_MARK_READY_DISABLED_HOLD } from "../commands/stack-work.mjs";
6
6
  import { buildPrShepherdCommand } from "./runner.mjs";
7
- const STACK_LAYER_BLOCK_REASONS = {
8
- closed: "was closed without merging",
9
- draft: "is still a draft",
10
- conflicting: "has merge conflicts",
11
- "queue-removal": "has an unacknowledged merge-queue removal",
12
- "failing-checks": "has failing checks",
13
- "review-work": "has unresolved review work",
14
- "checks-in-progress": "has checks in progress",
15
- "merge-state": "is not in a mergeable state",
16
- "no-ready-receipt": "has no current Shepherd READY receipt",
17
- "stale-ancestry": "is not rebased onto its parent layer's current head",
18
- };
19
7
  export function buildSimpleIterateInstructions(result) {
20
8
  switch (result.action) {
21
9
  case "wait":
@@ -76,20 +64,13 @@ function buildStackDraftHoldInstruction(result, hold) {
76
64
  formatPrUrl(result.repo, result.pr),
77
65
  "--until-terminal",
78
66
  ]).text;
79
- const lowerLayer = hold.kind === "lower-layer-not-ready" ? hold.lowerLayer : undefined;
80
67
  const handoff = `a \`--stack\` selector listed this session, finish that selector's remaining steps and rerun it with its original flags; otherwise run ${inlineCode(stackCommand)}, adding \`--merge\` when merging was requested.`;
81
- const instruction = lowerLayer
82
- ? `PR #${result.pr} stays in draft because lower stack layer PR #${lowerLayer.pr} ${STACK_LAYER_BLOCK_REASONS[lowerLayer.reason]}, so repeating this one-PR session cannot advance it. Advance PR #${lowerLayer.pr} first: if ${handoff}`
83
- : `PR #${result.pr} stays in draft because ${holdReason(hold)}, so repeating this one-PR session cannot advance it. If ${handoff}`;
68
+ const reason = hold.kind === "auto-mark-ready-disabled" ? AUTO_MARK_READY_DISABLED_HOLD : hold.kind;
69
+ const instruction = `PR #${result.pr} stays in draft because ${reason}, so repeating this one-PR session cannot advance it. If ${handoff}`;
84
70
  return result.quotaWarning
85
71
  ? buildQuotaAwareContinuation(result.quotaWarning, instruction)
86
72
  : instruction;
87
73
  }
88
- function holdReason(hold) {
89
- return hold.kind === "auto-mark-ready-disabled"
90
- ? AUTO_MARK_READY_DISABLED_HOLD
91
- : "its lower stack layers could not be verified";
92
- }
93
74
  export function adaptIterateLog(log) {
94
75
  return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
95
76
  }
@@ -43,12 +43,11 @@ function formatItem(item) {
43
43
  : "";
44
44
  const readyDelay = item.remainingSeconds !== undefined ? ` · ready delay \`${item.remainingSeconds}s\`` : "";
45
45
  const readyReceipt = item.readyReceipt ? " · Shepherd READY completion `verified`" : "";
46
- const blockedBy = item.blockedByPr ? ` · stack blocked by PR #${item.blockedByPr}` : "";
47
46
  const checks = item.checks;
48
47
  const review = item.review;
49
48
  return [
50
49
  `- [PR #${item.pr}: ${escapeMarkdownText(item.title)}](${item.url}) [${item.action.toUpperCase()}]`,
51
- ` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${readyReceipt}${blockedBy}${stack}`,
50
+ ` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${readyReceipt}${stack}`,
52
51
  ` - head \`${item.headRefName}\` at \`${item.headRefOid}\` · base \`${item.baseRefName}\``,
53
52
  ...(checks
54
53
  ? [` - checks: ${formatCounts(checks, checks.incomplete ? ", incomplete" : "")}`]
@@ -1,6 +1,6 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
2
  import { summarizeApiTelemetry } from "../../github/api-telemetry.mjs";
3
- import { evaluateWorktreeGraphqlQuotaWarning } from "../../state/graphql-quota-warnings.mjs";
3
+ import { selectQuotaWarning } from "../quota-selection.mjs";
4
4
  import { buildQuotaAwareContinuation } from "../../quota-warning.mjs";
5
5
  function shouldWarn(result) {
6
6
  return ["wait", "mark_ready", "merge", "fix_code"].includes(result.action);
@@ -10,14 +10,15 @@ export async function attachApiUsage(result, persistWarning, preservePersistedWa
10
10
  if (apiUsage === undefined)
11
11
  return result;
12
12
  let quotaWarning = preservePersistedWarning ? result.quotaWarning : undefined;
13
- if (quotaWarning === undefined && apiUsage.graphql !== undefined && shouldWarn(result)) {
13
+ const core = apiUsage.rest?.find((item) => item.resource === "core");
14
+ if (quotaWarning === undefined && shouldWarn(result) && (apiUsage.graphql || core)) {
14
15
  const [owner, repo] = result.repo.split("/");
15
16
  if (owner && repo) {
16
17
  const bands = loadConfig().watch.graphqlQuotaWarnings.map((band) => ({
17
18
  ...band,
18
19
  pollIntervalMinutes: Math.max(band.pollIntervalMinutes, minimumPollIntervalMinutes),
19
20
  }));
20
- quotaWarning = await evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands, apiUsage.graphql, persistWarning);
21
+ quotaWarning = await selectQuotaWarning({ owner, repo }, bands, apiUsage, persistWarning);
21
22
  }
22
23
  }
23
24
  const { quotaWarning: _deferredWarning, ...baseResult } = result;
@@ -22,7 +22,7 @@ import { fingerprintRawSummaryPr } from "../../github/poll-summary-fingerprint.m
22
22
  import { currentQueueRemovalEvent } from "../../github/poll-summary-queue-removal.mjs";
23
23
  import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
24
24
  import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
25
- import { findParentMarkReadyBlock, heldByLowerLayer, stackDraftHold } from "./parent-first.mjs";
25
+ import { stackDraftHold } from "./parent-first.mjs";
26
26
  import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
27
27
  export function runIterate(opts) {
28
28
  return withIterateApiUsage(opts, () => runIterateCore(opts));
@@ -175,11 +175,8 @@ async function runIterateCore(opts) {
175
175
  const canMarkReady = report.status === "READY" &&
176
176
  report.mergeStatus.isDraft &&
177
177
  !report.mergeStatus.blockingBotReviewInProgress;
178
- const parentBlock = canMarkReady
179
- ? await findParentMarkReadyBlock(report, { owner: repoOwner, name: repoName })
180
- : undefined;
181
178
  const autoMarkReady = !opts.noAutoMarkReady && config.actions.autoMarkReady;
182
- const markReadyResult = await markReadyIfAuthorized(canMarkReady && !parentBlock && autoMarkReady, base, report);
179
+ const markReadyResult = await markReadyIfAuthorized(canMarkReady && autoMarkReady, base, report);
183
180
  if (markReadyResult)
184
181
  return markReadyResult;
185
182
  if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
@@ -209,19 +206,13 @@ async function runIterateCore(opts) {
209
206
  log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
210
207
  };
211
208
  }
212
- const hold = stackDraftHold(report, autoMarkReady, parentBlock);
209
+ const hold = stackDraftHold(report, autoMarkReady);
213
210
  const wait = {
214
211
  ...base,
215
212
  action: "wait",
216
213
  log: buildWaitLog(base),
217
214
  ...(hold && { stackDraftHold: hold }),
218
215
  };
219
- // The lower layer's own session owns progress here, so this draft cannot
220
- // stall; its stall clock restarts once that layer releases it.
221
- if (heldByLowerLayer(wait)) {
222
- await clearStallState(stallKey);
223
- return wait;
224
- }
225
216
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, report, reviewSummaryIds);
226
217
  }
227
218
  async function recordReadyReceipt(key, report) {
@@ -1,25 +1,8 @@
1
- import type { RepoInfo } from "../../github/client.mts";
2
- import type { IterateResult, ShepherdReport, StackDraftHold, StackLowerLayerBlock } from "../../types.mts";
1
+ import type { ShepherdReport, StackDraftHold } from "../../types.mts";
3
2
  /**
4
- * What keeps a draft child from being marked ready: the lowest blocking lower layer,
5
- * or a stack read that could not attribute the block to one.
3
+ * A native stack draft this one-PR session cannot promote because it is ready
4
+ * and automatic mark-ready is off. A draft that is not ready yet keeps the
5
+ * ordinary wait. A clean draft is marked ready on its own, without waiting
6
+ * for lower layers.
6
7
  */
7
- export type ParentMarkReadyBlock = StackLowerLayerBlock | "unverifiable";
8
- /**
9
- * Draft children may only be converted after their immediate parent has
10
- * independently completed a one-PR ready-delay and the stack boundary is
11
- * still linear. A failed or incomplete stack read blocks this mutation but
12
- * does not block ordinary review/CI work in the caller.
13
- */
14
- export declare function findParentMarkReadyBlock(report: ShepherdReport, repo: RepoInfo): Promise<ParentMarkReadyBlock | undefined>;
15
- /**
16
- * A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
17
- * automatic mark-ready is off. Undefined when the session can still advance the PR by
18
- * iterating.
19
- */
20
- export declare function stackDraftHold(report: ShepherdReport, autoMarkReady: boolean, parentBlock: ParentMarkReadyBlock | undefined): StackDraftHold | undefined;
21
- /**
22
- * The lower layer a held draft waits on. That layer's own session owns this draft's
23
- * progress, so the draft neither stalls nor keeps polling while it waits.
24
- */
25
- export declare function heldByLowerLayer(result: IterateResult): StackLowerLayerBlock | undefined;
8
+ export declare function stackDraftHold(report: ShepherdReport, autoMarkReady: boolean): StackDraftHold | undefined;
@@ -1,70 +1,13 @@
1
- import { fetchPollSummary } from "../../github/poll-summary.mjs";
2
- import { stackLayerBlockReason } from "../stack-layer-readiness.mjs";
3
1
  /**
4
- * Draft children may only be converted after their immediate parent has
5
- * independently completed a one-PR ready-delay and the stack boundary is
6
- * still linear. A failed or incomplete stack read blocks this mutation but
7
- * does not block ordinary review/CI work in the caller.
2
+ * A native stack draft this one-PR session cannot promote because it is ready
3
+ * and automatic mark-ready is off. A draft that is not ready yet keeps the
4
+ * ordinary wait. A clean draft is marked ready on its own, without waiting
5
+ * for lower layers.
8
6
  */
9
- export async function findParentMarkReadyBlock(report, repo) {
10
- const stack = report.mergeStatus.mergeRequirements?.stack;
11
- if (!stack || stack.position === 1)
12
- return undefined;
13
- if (stack.position < 1)
14
- return "unverifiable";
15
- try {
16
- const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
17
- const child = summary.prs.find((item) => item.pr === report.pr);
18
- const lowerLayers = summary.prs
19
- .filter((item) => (item.stack?.position ?? Number.MAX_SAFE_INTEGER) < stack.position)
20
- .sort((left, right) => (left.stack?.position ?? Number.MAX_SAFE_INTEGER) -
21
- (right.stack?.position ?? Number.MAX_SAFE_INTEGER));
22
- if (!child || child.state !== "OPEN" || lowerLayers.length !== stack.position - 1)
23
- return "unverifiable";
24
- // A stale boundary means that layer is no longer based on the parent it was
25
- // reviewed against. Gaps above this child do not affect its promotion boundary.
26
- const staleChildren = new Set(summary.stackAncestry?.map((gap) => gap.childPr));
27
- for (const layer of lowerLayers) {
28
- // A merged layer is already satisfied; GitHub may have retargeted the
29
- // layer above it to the trunk as part of the merge.
30
- if (layer.state === "MERGED")
31
- continue;
32
- const reason = staleChildren.has(layer.pr) ? "stale-ancestry" : stackLayerBlockReason(layer);
33
- if (reason)
34
- return { pr: layer.pr, reason };
35
- }
36
- // This layer's own stale boundary belongs to its own session's repair; the
37
- // earlier stale-ancestry read missed it, so this snapshot is unsettled.
38
- return staleChildren.has(report.pr) ? "unverifiable" : undefined;
39
- }
40
- catch {
41
- // Never convert a child draft based on an unverifiable parent.
42
- return "unverifiable";
43
- }
44
- }
45
- /**
46
- * A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
47
- * automatic mark-ready is off. Undefined when the session can still advance the PR by
48
- * iterating.
49
- */
50
- export function stackDraftHold(report, autoMarkReady, parentBlock) {
7
+ export function stackDraftHold(report, autoMarkReady) {
51
8
  if (!report.mergeStatus.mergeRequirements?.stack || !report.mergeStatus.isDraft)
52
9
  return undefined;
53
- // Any lower-layer block outranks the session flag: even with automatic
54
- // mark-ready enabled, this draft cannot advance until that layer does, and a
55
- // lower-layer read that failed must not hide behind the flag.
56
- if (parentBlock === "unverifiable")
57
- return { kind: "lower-layer-not-ready" };
58
- if (parentBlock)
59
- return { kind: "lower-layer-not-ready", lowerLayer: parentBlock };
10
+ if (report.status !== "READY" || report.mergeStatus.blockingBotReviewInProgress)
11
+ return undefined;
60
12
  return autoMarkReady ? undefined : { kind: "auto-mark-ready-disabled" };
61
13
  }
62
- /**
63
- * The lower layer a held draft waits on. That layer's own session owns this draft's
64
- * progress, so the draft neither stalls nor keeps polling while it waits.
65
- */
66
- export function heldByLowerLayer(result) {
67
- return result.action === "wait" && result.stackDraftHold?.kind === "lower-layer-not-ready"
68
- ? result.stackDraftHold.lowerLayer
69
- : undefined;
70
- }
@@ -1,10 +1,22 @@
1
1
  import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
- import type { GraphqlApiUsage, PollSummaryResult } from "../types.mts";
3
- /** Sleep at least `--interval`, and at least the active crossed quota band. */
4
- export declare function graphqlQuotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: Pick<GraphqlApiUsage, "remaining" | "limit"> | undefined, fallbackMs: number, maxMs: number): number;
2
+ import type { ApiResourceUsage, GraphqlApiUsage, PollSummaryResult } from "../types.mts";
3
+ /** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
4
+ export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: {
5
+ graphql?: Pick<GraphqlApiUsage, "remaining" | "limit">;
6
+ rest?: Pick<ApiResourceUsage, "resource" | "remaining" | "limit">[];
7
+ } | undefined, fallbackMs: number, maxMs: number): number;
8
+ export interface RateLimitRetry {
9
+ ms: number;
10
+ resource: string;
11
+ remaining?: number;
12
+ limit?: number;
13
+ resetAt?: number;
14
+ }
5
15
  /**
6
- * Retry delay for `--until-terminal` when GitHub returns a GraphQL 429 / secondary
7
- * limit. `null` means the error is not a retryable rate limit.
16
+ * Retry delay for `--until-terminal` when GitHub exhausts a primary quota or
17
+ * returns a secondary limit. `null` means the error is not a retryable rate limit.
8
18
  */
9
- export declare function pollGraphQlRetryAfterMs(err: unknown): number | null;
19
+ export declare function pollRateLimitRetryAfterMs(err: unknown): RateLimitRetry | null;
20
+ /** Stderr line naming the exhausted budget and when the sleep ends. */
21
+ export declare function formatRateLimitRetryLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
10
22
  export declare function aggregateQuotaWarning(result: PollSummaryResult, bands: GraphqlQuotaWarningBand[], intervalSeconds: number): Promise<PollSummaryResult["quotaWarning"]>;
@@ -1,10 +1,10 @@
1
- import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
2
1
  import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
3
2
  import { GitHubRequestError } from "../github/errors.mjs";
4
3
  import { isRateLimitMessage } from "../comments/rate-limit.mjs";
4
+ import { selectQuotaWarning } from "./quota-selection.mjs";
5
5
  const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
6
6
  /** Sleep at least `--interval`, and at least the active crossed quota band. */
7
- export function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
7
+ function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
8
8
  if (usage === undefined || usage.limit <= 0 || bands.length === 0) {
9
9
  return Math.min(fallbackMs, maxMs);
10
10
  }
@@ -15,33 +15,71 @@ export function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
15
15
  const bandMs = active.pollIntervalMinutes * 60_000;
16
16
  return Math.min(Math.max(fallbackMs, bandMs), maxMs);
17
17
  }
18
+ /** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
19
+ export function quotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
20
+ const core = usage?.rest?.find((item) => item.resource === "core");
21
+ return Math.max(graphqlQuotaPollIntervalMs(bands, usage?.graphql, fallbackMs, maxMs), graphqlQuotaPollIntervalMs(bands, core, fallbackMs, maxMs));
22
+ }
18
23
  /**
19
- * Retry delay for `--until-terminal` when GitHub returns a GraphQL 429 / secondary
20
- * limit. `null` means the error is not a retryable rate limit.
24
+ * Retry delay for `--until-terminal` when GitHub exhausts a primary quota or
25
+ * returns a secondary limit. `null` means the error is not a retryable rate limit.
21
26
  */
22
- export function pollGraphQlRetryAfterMs(err) {
27
+ export function pollRateLimitRetryAfterMs(err) {
23
28
  if (!(err instanceof GitHubRequestError))
24
29
  return null;
25
- const retryable = err.status === 429 ||
26
- err.retryAfterSeconds !== undefined ||
27
- isRateLimitMessage(err.message) ||
28
- (err.graphqlErrors?.some((error) => isRateLimitMessage(error.message)) ?? false) ||
29
- (err.rateLimit !== undefined && err.rateLimit.remaining <= 0);
30
+ const rateLimitMessage = isRateLimitMessage(err.message) ||
31
+ (err.graphqlErrors?.some((error) => isRateLimitMessage(error.message)) ?? false);
32
+ const exhausted = err.rateLimit !== undefined && err.rateLimit.remaining <= 0;
33
+ const retryable = err.status === 429 || err.retryAfterSeconds !== undefined || rateLimitMessage || exhausted;
30
34
  if (!retryable)
31
35
  return null;
32
- if (err.retryAfterSeconds !== undefined)
33
- return Math.max(err.retryAfterSeconds, 0) * 1000;
34
- if (err.rateLimit !== undefined && err.rateLimit.remaining <= 0) {
35
- return Math.max(err.rateLimit.resetAt * 1000 - Date.now(), 0);
36
+ const resource = retryResource(err);
37
+ const rateLimit = err.rateLimit;
38
+ const details = {
39
+ resource,
40
+ ...(rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
41
+ ...(rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
42
+ ...(rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
43
+ };
44
+ if (err.retryAfterSeconds !== undefined) {
45
+ return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
36
46
  }
37
- return GRAPHQL_RETRY_AFTER_DEFAULT_MS;
47
+ if (exhausted && rateLimit !== undefined) {
48
+ return { ...details, ms: Math.max(rateLimit.resetAt * 1000 - Date.now(), 0) };
49
+ }
50
+ return { ...details, ms: GRAPHQL_RETRY_AFTER_DEFAULT_MS };
51
+ }
52
+ function retryResource(err) {
53
+ if (err.rateLimit?.resource)
54
+ return err.rateLimit.resource;
55
+ const secondary = err.status === 429 ||
56
+ err.retryAfterSeconds !== undefined ||
57
+ /secondary/i.test(err.message) ||
58
+ (err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
59
+ return secondary ? "secondary" : "graphql";
60
+ }
61
+ /** Stderr line naming the exhausted budget and when the sleep ends. */
62
+ export function formatRateLimitRetryLine(tickLabel, elapsedSeconds, retry) {
63
+ const resetAt = retry.resetAt ?? Math.ceil((Date.now() + retry.ms) / 1000);
64
+ const clock = `${new Date(resetAt * 1000).toISOString().slice(11, 19)}Z`;
65
+ const label = retry.resource === "graphql"
66
+ ? "GitHub GraphQL rate limit"
67
+ : retry.resource === "secondary"
68
+ ? "GitHub secondary rate limit"
69
+ : `GitHub REST ${retry.resource} rate limit`;
70
+ const counts = retry.remaining !== undefined && retry.limit !== undefined
71
+ ? ` (${retry.remaining}/${retry.limit})`
72
+ : "";
73
+ return `[${tickLabel} / +${elapsedSeconds}s] ${label}${counts} — retrying at ${clock} (in ${Math.round(retry.ms / 1000)}s)\n`;
38
74
  }
39
75
  export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
40
- const usage = summarizeApiTelemetry()?.graphql;
76
+ const usage = summarizeApiTelemetry();
41
77
  const [owner, repo] = result.repo.split("/");
42
78
  if (!usage || !owner || !repo)
43
79
  return undefined;
44
- return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
80
+ if (!usage.graphql && !usage.rest?.some((item) => item.resource === "core"))
81
+ return undefined;
82
+ return selectQuotaWarning({ owner, repo }, bands.map((band) => ({
45
83
  ...band,
46
84
  pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
47
85
  })), usage, true);
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
3
3
  import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
4
- import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planBottomDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
4
+ import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planPrefixDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
5
5
  import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
6
6
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
7
7
  export function withPollSummaryInstructions(result, mergeRequested) {
@@ -14,59 +14,44 @@ export function planPollSummary(result, mergeRequested) {
14
14
  }
15
15
  const prs = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
16
16
  const staleChildren = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
17
- const firstUnready = prs.find((item) => item.state === "OPEN" && (!isStackLayerReady(item) || staleChildren.has(item.pr)));
18
- const blocked = prs.map((item) => firstUnready && item.state === "OPEN" && stackPosition(item) > stackPosition(firstUnready)
19
- ? {
20
- ...item,
21
- ...(["cancel", "mark_ready", "merge"].includes(item.action) && {
22
- action: "wait",
23
- reasons: [...item.reasons, "lower-layer-not-ready"],
24
- }),
25
- blockedByPr: firstUnready.pr,
26
- ...(item.isDraft &&
27
- item.pollCommand && {
28
- pollCommand: item.pollCommand.replace(" --until-terminal", " --timeout 1s --debounce 0s") +
29
- (item.pollCommand.includes("--no-auto-mark-ready") ? "" : " --no-auto-mark-ready"),
30
- pollProbe: true,
31
- }),
32
- }
33
- : item.state === "OPEN" &&
34
- (!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
35
- ["cancel", "merge"].includes(item.action)
36
- ? {
37
- ...item,
38
- action: "fix_code",
39
- reasons: [
40
- ...item.reasons,
41
- staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
42
- ],
43
- }
44
- : item);
45
17
  const projected = {
46
18
  ...result,
47
- prs: blocked.map((item) => {
48
- if (item.state === "OPEN" && item.isInMergeQueue && item.action === "cancel") {
49
- return {
19
+ prs: prs.map((item) => {
20
+ const needsOwnSession = item.state === "OPEN" &&
21
+ (!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
22
+ ["cancel", "merge"].includes(item.action);
23
+ const layer = needsOwnSession
24
+ ? {
50
25
  ...item,
26
+ action: "fix_code",
27
+ reasons: [
28
+ ...item.reasons,
29
+ staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
30
+ ],
31
+ }
32
+ : item;
33
+ if (layer.state === "OPEN" && layer.isInMergeQueue && layer.action === "cancel") {
34
+ return {
35
+ ...layer,
51
36
  action: "wait",
52
- reasons: [...item.reasons, "already-in-merge-queue"],
37
+ reasons: [...layer.reasons, "already-in-merge-queue"],
53
38
  };
54
39
  }
55
- if (item.state === "CLOSED" && closedDependency(blocked, item.pr)) {
40
+ if (layer.state === "CLOSED" && closedDependency(prs, layer.pr)) {
56
41
  return {
57
- ...item,
42
+ ...layer,
58
43
  action: "escalate",
59
- reasons: [...item.reasons, "closed-unmerged-dependency"],
44
+ reasons: [...layer.reasons, "closed-unmerged-dependency"],
60
45
  };
61
46
  }
62
- if (item.state !== "OPEN" && item.state !== "MERGED") {
47
+ if (layer.state !== "OPEN" && layer.state !== "MERGED") {
63
48
  return {
64
- ...item,
49
+ ...layer,
65
50
  action: "escalate",
66
- reasons: [...item.reasons, "unverified-stack-state"],
51
+ reasons: [...layer.reasons, "unverified-stack-state"],
67
52
  };
68
53
  }
69
- return item;
54
+ return layer;
70
55
  }),
71
56
  };
72
57
  const planned = planStack(projected, mergeRequested);
@@ -100,7 +85,7 @@ function planStack(result, mergeRequested) {
100
85
  const runnableCandidates = work.sessions.filter((item) => item.pollCommand);
101
86
  const missingCommands = work.sessions.filter((item) => !item.pollCommand);
102
87
  const agentWork = runnableCandidates.length > 0 || work.markReady.length > 0;
103
- const drain = planBottomDrain(result, mergeRequested);
88
+ const drain = planPrefixDrain(result, mergeRequested);
104
89
  if (drain)
105
90
  return drain;
106
91
  const handoffs = findHumanHandoffs(result);
@@ -5,7 +5,7 @@ import { getRepoInfo } from "../github/client.mjs";
5
5
  import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
6
6
  import { fetchPollSummary } from "../github/poll-summary.mjs";
7
7
  import { sleep } from "../util/sleep.mjs";
8
- import { aggregateQuotaWarning, graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs, } from "./poll-quota.mjs";
8
+ import { aggregateQuotaWarning, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
9
9
  import { planPollSummary, withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
10
10
  import { summaryStatusSignature } from "./poll-summary-signature.mjs";
11
11
  import { applyStackStallGuard } from "./stack-stall.mjs";
@@ -83,12 +83,12 @@ async function runAggregatePollCore(opts) {
83
83
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
84
84
  }, opts.merge);
85
85
  }
86
- const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
87
- if (retryMs === null || rateLimitRetries >= 1)
86
+ const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(error) : null;
87
+ if (retry === null || rateLimitRetries >= 1)
88
88
  throw error;
89
89
  rateLimitRetries += 1;
90
- process.stderr.write(`[aggregate poll tick ${tick} / +${Math.round((Date.now() - start) / 1000)}s] GraphQL rate limit — retrying in ${Math.round(retryMs / 1000)}s\n`);
91
- await sleep(retryMs);
90
+ process.stderr.write(formatRateLimitRetryLine(`aggregate poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
91
+ await sleep(retry.ms);
92
92
  continue;
93
93
  }
94
94
  const allTerminal = last.selection.kind === "stack"
@@ -142,7 +142,7 @@ async function runAggregatePollCore(opts) {
142
142
  const elapsedMs = Date.now() - start;
143
143
  const sleepMs = debounceUntil
144
144
  ? Math.min(intervalMs, Math.max(debounceUntil - Date.now(), 0))
145
- : graphqlQuotaPollIntervalMs(quotaBands, summarizeApiTelemetry()?.graphql, intervalMs, MAX_TIMER_MS);
145
+ : quotaPollIntervalMs(quotaBands, summarizeApiTelemetry(), intervalMs, MAX_TIMER_MS);
146
146
  if (!opts.untilTerminal && debounceUntil === null) {
147
147
  const remainingMs = timeoutMs - elapsedMs;
148
148
  if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {