pr-shepherd 0.51.1 → 0.52.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -18
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +4 -3
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -2
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-instructions.mjs +41 -0
- package/bin/cli/iterate-lean.mjs +1 -3
- package/bin/cli/poll-summary-emitter.mjs +2 -3
- package/bin/cli/poll-summary-formatter.mjs +12 -3
- package/bin/cli/runner.d.mts +1 -0
- package/bin/cli/runner.mjs +3 -1
- package/bin/commands/check.mjs +14 -4
- package/bin/commands/clean.mjs +7 -13
- package/bin/commands/iterate/check-instructions.d.mts +2 -2
- package/bin/commands/iterate/check-instructions.mjs +11 -7
- package/bin/commands/iterate/escalate.mjs +4 -13
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +20 -43
- package/bin/commands/iterate/helpers.d.mts +0 -1
- package/bin/commands/iterate/helpers.mjs +0 -15
- package/bin/commands/iterate/index.mjs +178 -11
- package/bin/commands/iterate/merge-state.mjs +35 -18
- package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
- package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
- package/bin/commands/iterate/parent-first.d.mts +25 -0
- package/bin/commands/iterate/parent-first.mjs +70 -0
- package/bin/commands/iterate/render.d.mts +1 -1
- package/bin/commands/iterate/render.mjs +7 -12
- package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
- package/bin/commands/iterate/stale-ancestry.mjs +40 -0
- package/bin/commands/iterate/stall.mjs +40 -3
- package/bin/commands/poll-progress.d.mts +5 -1
- package/bin/commands/poll-progress.mjs +7 -1
- package/bin/commands/poll-summary-instructions.d.mts +6 -1
- package/bin/commands/poll-summary-instructions.mjs +151 -107
- package/bin/commands/poll-summary.mjs +32 -6
- package/bin/commands/poll.mjs +3 -1
- package/bin/commands/ready-delay.d.mts +9 -4
- package/bin/commands/ready-delay.mjs +34 -20
- package/bin/commands/shepherd-journal.mjs +4 -1
- package/bin/commands/stack-drain.d.mts +35 -0
- package/bin/commands/stack-drain.mjs +129 -0
- package/bin/commands/stack-layer-readiness.d.mts +7 -0
- package/bin/commands/stack-layer-readiness.mjs +35 -0
- package/bin/commands/stack-stall.d.mts +14 -0
- package/bin/commands/stack-stall.mjs +69 -0
- package/bin/commands/stack-work.d.mts +32 -0
- package/bin/commands/stack-work.mjs +39 -0
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +10 -0
- package/bin/config.json +1 -0
- package/bin/exit-codes.d.mts +2 -0
- package/bin/exit-codes.mjs +2 -0
- package/bin/github/batch-parsers.mjs +1 -0
- package/bin/github/batch-raw-types.d.mts +2 -0
- package/bin/github/errors.d.mts +5 -0
- package/bin/github/errors.mjs +4 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-stack-summary.gql +8 -2
- package/bin/github/gql/poll-stack-topology.gql +38 -0
- package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
- package/bin/github/gql/poll-summary-check-page.gql +23 -0
- package/bin/github/gql/poll-summary-fragment.gql +53 -56
- package/bin/github/merge-queue-checks.mjs +10 -1
- package/bin/github/poll-summary-check-hydration.d.mts +12 -0
- package/bin/github/poll-summary-check-hydration.mjs +55 -0
- package/bin/github/poll-summary-fingerprint.d.mts +8 -0
- package/bin/github/poll-summary-fingerprint.mjs +48 -0
- package/bin/github/poll-summary-projector.mjs +70 -16
- package/bin/github/poll-summary-queue-removal.d.mts +5 -0
- package/bin/github/poll-summary-queue-removal.mjs +25 -0
- package/bin/github/poll-summary-raw.d.mts +52 -31
- package/bin/github/poll-summary-readiness.d.mts +6 -0
- package/bin/github/poll-summary-readiness.mjs +25 -0
- package/bin/github/poll-summary-route.mjs +8 -5
- package/bin/github/poll-summary.d.mts +3 -0
- package/bin/github/poll-summary.mjs +21 -73
- package/bin/github/queries.d.mts +7 -0
- package/bin/github/queries.mjs +9 -1
- package/bin/github/queue-removal-freshness.d.mts +16 -0
- package/bin/github/queue-removal-freshness.mjs +26 -0
- package/bin/github/stack-read.d.mts +34 -0
- package/bin/github/stack-read.mjs +92 -0
- package/bin/log/log-file.d.mts +1 -1
- package/bin/log/log-file.mjs +4 -17
- package/bin/state/base.d.mts +18 -1
- package/bin/state/base.mjs +65 -13
- package/bin/state/fix-attempts.d.mts +1 -1
- package/bin/state/fix-attempts.mjs +1 -1
- package/bin/state/graphql-quota-warnings.mjs +2 -6
- package/bin/state/iterate-stall.d.mts +7 -15
- package/bin/state/iterate-stall.mjs +6 -64
- package/bin/state/ready-receipts.d.mts +45 -0
- package/bin/state/ready-receipts.mjs +86 -0
- package/bin/state/rest-cache.d.mts +1 -1
- package/bin/state/rest-cache.mjs +1 -1
- package/bin/state/stack-stall.d.mts +16 -0
- package/bin/state/stack-stall.mjs +12 -0
- package/bin/state/stall-state-store.d.mts +37 -0
- package/bin/state/stall-state-store.mjs +74 -0
- package/bin/types/escalate.d.mts +2 -3
- package/bin/types/github.d.mts +2 -0
- package/bin/types/iterate.d.mts +2 -1
- package/bin/types/merge-requirements.d.mts +19 -0
- package/bin/types/poll-summary.d.mts +17 -2
- package/bin/types/report.d.mts +2 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
- package/bin/state/bot-cr-seen.d.mts +0 -51
- package/bin/state/bot-cr-seen.mjs +0 -100
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { fetchPollSummary } from "../../github/poll-summary.mjs";
|
|
2
|
+
import { stackLayerBlockReason } from "../stack-layer-readiness.mjs";
|
|
3
|
+
/**
|
|
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.
|
|
8
|
+
*/
|
|
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) {
|
|
51
|
+
if (!report.mergeStatus.mergeRequirements?.stack || !report.mergeStatus.isDraft)
|
|
52
|
+
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 };
|
|
60
|
+
return autoMarkReady ? undefined : { kind: "auto-mark-ready-disabled" };
|
|
61
|
+
}
|
|
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
|
+
}
|
|
@@ -2,4 +2,4 @@ import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, Fir
|
|
|
2
2
|
/** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
|
|
3
3
|
export declare function renderResolveCommand(rc: ResolveCommand): string;
|
|
4
4
|
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): string[];
|
|
5
|
+
isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean, stackRebase?: string): string[];
|
|
@@ -4,6 +4,7 @@ import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, buildShepherdJournalInstruction,
|
|
|
4
4
|
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
|
+
import { buildBranchPushInstruction, buildConflictInstruction } from "./native-stack-rebase.mjs";
|
|
7
8
|
/** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
|
|
8
9
|
export function renderResolveCommand(rc) {
|
|
9
10
|
const parts = [...rc.argv];
|
|
@@ -12,14 +13,11 @@ export function renderResolveCommand(rc) {
|
|
|
12
13
|
return renderShellCommand(parts);
|
|
13
14
|
}
|
|
14
15
|
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, _cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], _inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
|
|
15
|
-
isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
|
|
16
|
+
isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, stackRebase) {
|
|
16
17
|
const instructions = [];
|
|
17
18
|
const { locatedThreads, unlocatedMutatedThreads, unlocatedThreads } = partitionFixThreads(threads, resolveCommand, resolveOnlyCommand);
|
|
18
19
|
const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
|
|
19
|
-
const repeatedWorkflowBranchRecoveryInstructions = buildRepeatedWorkflowBranchRecoveryInstructions(baseBranch, hasExhaustedWorkflowRerun, {
|
|
20
|
-
isBehind,
|
|
21
|
-
hasConflicts,
|
|
22
|
-
});
|
|
20
|
+
const repeatedWorkflowBranchRecoveryInstructions = buildRepeatedWorkflowBranchRecoveryInstructions(baseBranch, hasExhaustedWorkflowRerun, { isBehind, hasConflicts }, stackRebase);
|
|
23
21
|
const hasRepeatedWorkflowBranchRecovery = repeatedWorkflowBranchRecoveryInstructions.length > 0;
|
|
24
22
|
const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
|
|
25
23
|
const hasNonConflictHints = threads.length > 0 ||
|
|
@@ -41,7 +39,7 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
|
|
|
41
39
|
instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
|
|
42
40
|
}
|
|
43
41
|
if (hasConflicts && !hasRepeatedWorkflowBranchRecovery) {
|
|
44
|
-
instructions.push(
|
|
42
|
+
instructions.push(buildConflictInstruction(stackRebase));
|
|
45
43
|
}
|
|
46
44
|
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
47
45
|
if (firstLookTotal > 0) {
|
|
@@ -82,11 +80,8 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
|
|
|
82
80
|
instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
|
|
83
81
|
const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
|
|
84
82
|
const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
|
|
85
|
-
if (hasConflicts) {
|
|
86
|
-
instructions.push(
|
|
87
|
-
}
|
|
88
|
-
else if (hasRepeatedWorkflowBranchRecovery) {
|
|
89
|
-
instructions.push("Push the updated PR head branch before iterating immediately.");
|
|
83
|
+
if (hasConflicts || hasRepeatedWorkflowBranchRecovery) {
|
|
84
|
+
instructions.push(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix));
|
|
90
85
|
}
|
|
91
86
|
else if (hasNonConflictHints) {
|
|
92
87
|
instructions.push("If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate immediately with the same options. If you did not change code, do not commit and continue with the remaining steps.");
|
|
@@ -101,6 +96,6 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
|
|
|
101
96
|
}
|
|
102
97
|
if (resolveOnlyCommand?.hasMutations)
|
|
103
98
|
instructions.push("Run the `resolve-only:` command shown above.");
|
|
104
|
-
instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
|
|
99
|
+
instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha, stackRebase !== undefined));
|
|
105
100
|
return instructions;
|
|
106
101
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RepoInfo } from "../../github/client.mts";
|
|
2
|
+
import type { PollSummaryStackAncestry } from "../../types.mts";
|
|
3
|
+
import type { ShepherdReport } from "../../types/report.mts";
|
|
4
|
+
/**
|
|
5
|
+
* A verified stale boundary for the PR being shepherded.
|
|
6
|
+
*
|
|
7
|
+
* The stack topology read is authoritative for this check: it compares the
|
|
8
|
+
* child's recorded base OID with the current head OID of its immediate open
|
|
9
|
+
* parent. GitHub can report both PRs CLEAN while this boundary is stale.
|
|
10
|
+
*/
|
|
11
|
+
export interface StaleNativeStackAncestry extends PollSummaryStackAncestry {
|
|
12
|
+
instructions: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Find a stale immediate parent boundary for one PR.
|
|
16
|
+
*
|
|
17
|
+
* This helper is intentionally read-only. A null result means either the PR is
|
|
18
|
+
* not a non-root native-stack layer, its boundary is current, or the boundary
|
|
19
|
+
* could not be verified. Callers must not emit a repair command from an
|
|
20
|
+
* unverified snapshot.
|
|
21
|
+
*/
|
|
22
|
+
export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo): Promise<StaleNativeStackAncestry | null>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readStackTopology, stackAncestryGaps } from "../../github/stack-read.mjs";
|
|
2
|
+
import { buildNativeStackRebaseInstruction } from "./native-stack-rebase.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Find a stale immediate parent boundary for one PR.
|
|
5
|
+
*
|
|
6
|
+
* This helper is intentionally read-only. A null result means either the PR is
|
|
7
|
+
* not a non-root native-stack layer, its boundary is current, or the boundary
|
|
8
|
+
* could not be verified. Callers must not emit a repair command from an
|
|
9
|
+
* unverified snapshot.
|
|
10
|
+
*/
|
|
11
|
+
export async function findStaleNativeStackAncestry(report, repo) {
|
|
12
|
+
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
13
|
+
if (!stack || stack.position <= 1)
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const topology = await readStackTopology(report.pr, repo);
|
|
17
|
+
const ancestry = stackAncestryGaps(topology.ordered).find((gap) => gap.childPr === report.pr);
|
|
18
|
+
if (!ancestry)
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
...ancestry,
|
|
22
|
+
instructions: buildStaleNativeStackAncestryInstructions(repo, stack.number, ancestry),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// A stale repair is safe only when both OIDs were observed together. Let
|
|
27
|
+
// the caller retain the ordinary WAIT/ESCALATE path on an unreadable stack.
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Build the one-PR repair guidance after a stale boundary was verified. */
|
|
32
|
+
function buildStaleNativeStackAncestryInstructions(repo, stackNumber, ancestry) {
|
|
33
|
+
return [
|
|
34
|
+
`PR #${ancestry.childPr} records base \`${ancestry.childBaseRefName}\` at \`${ancestry.childBaseRefOid}\`, but its open parent PR #${ancestry.parentPr} currently ends at \`${ancestry.parentHeadRefName}\` \`${ancestry.parentHeadRefOid}\`.`,
|
|
35
|
+
buildNativeStackRebaseInstruction(`${repo.owner}/${repo.name}`, stackNumber, {
|
|
36
|
+
parentBranch: ancestry.parentHeadRefName,
|
|
37
|
+
}),
|
|
38
|
+
"Push the rewritten stack with `gh stack push`.",
|
|
39
|
+
];
|
|
40
|
+
}
|
|
@@ -90,12 +90,27 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
|
|
93
|
-
const
|
|
93
|
+
const read = await readStallState(stallKey);
|
|
94
|
+
if (!read.ok) {
|
|
95
|
+
if (stallTimeoutSeconds <= 0)
|
|
96
|
+
return prospectiveResult;
|
|
97
|
+
return stallStateUnavailable(base, prReference, prospectiveResult, read.reason);
|
|
98
|
+
}
|
|
99
|
+
const stored = read.state;
|
|
100
|
+
const persistTimer = async () => {
|
|
101
|
+
const wrote = await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
|
|
102
|
+
if (!wrote.ok && stallTimeoutSeconds > 0) {
|
|
103
|
+
return stallStateUnavailable(base, prReference, prospectiveResult, wrote.reason);
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
};
|
|
94
107
|
if (stored && stored.fingerprint === fingerprint) {
|
|
95
108
|
const ageSeconds = nowSeconds - stored.firstSeenAt;
|
|
96
109
|
if (ageSeconds < 0) {
|
|
97
110
|
// Clock skew: stored timestamp is in the future. Reset to avoid perpetually negative age.
|
|
98
|
-
|
|
111
|
+
const failed = await persistTimer();
|
|
112
|
+
if (failed)
|
|
113
|
+
return failed;
|
|
99
114
|
}
|
|
100
115
|
else if (stallTimeoutSeconds <= 0) {
|
|
101
116
|
// Stall detection disabled: refresh so re-enabling starts a fresh timer.
|
|
@@ -126,9 +141,31 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
|
|
|
126
141
|
return prospectiveResult;
|
|
127
142
|
}
|
|
128
143
|
// Fingerprint changed or no prior state — reset the stall timer.
|
|
129
|
-
|
|
144
|
+
const failed = await persistTimer();
|
|
145
|
+
if (failed)
|
|
146
|
+
return failed;
|
|
130
147
|
return prospectiveResult;
|
|
131
148
|
}
|
|
149
|
+
function stallStateUnavailable(base, prReference, prospectiveResult, reason) {
|
|
150
|
+
const pending = pendingReviewCommandsFromResult(prospectiveResult);
|
|
151
|
+
const escalateBase = {
|
|
152
|
+
triggers: ["stall-state-unavailable"],
|
|
153
|
+
unresolvedThreads: [],
|
|
154
|
+
ambiguousComments: [],
|
|
155
|
+
changesRequestedReviews: [],
|
|
156
|
+
...surfacedSummariesFromResult(prospectiveResult),
|
|
157
|
+
...(pending && { pendingReviewCommands: pending }),
|
|
158
|
+
suggestion: buildEscalateSuggestion(["stall-state-unavailable"], reason),
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
action: "escalate",
|
|
163
|
+
escalate: {
|
|
164
|
+
...escalateBase,
|
|
165
|
+
humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
132
169
|
function findCiStartStalledChecks(checks, nowSeconds, opts) {
|
|
133
170
|
if (opts.stallTimeoutSeconds <= 0 || opts.action !== "wait")
|
|
134
171
|
return [];
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { IterateResult } from "../types.mts";
|
|
2
|
+
type WaitResult = Extract<IterateResult, {
|
|
3
|
+
action: "wait";
|
|
4
|
+
}>;
|
|
2
5
|
export declare function writeWaitProgress(opts: {
|
|
3
6
|
tick: number;
|
|
4
7
|
elapsedMs: number;
|
|
5
8
|
sleepMs: number;
|
|
6
|
-
result:
|
|
9
|
+
result: WaitResult;
|
|
7
10
|
quietStatus: boolean;
|
|
8
11
|
verbose: boolean;
|
|
9
12
|
lastWaitSignature: string | null;
|
|
10
13
|
}): string | null;
|
|
11
14
|
export declare function writeDebounceProgress(tick: number, elapsedMs: number, remainingMs: number): void;
|
|
15
|
+
export {};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
function writeTickProgress(tick, elapsedSeconds, detail) {
|
|
2
2
|
process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — ${detail}\n`);
|
|
3
3
|
}
|
|
4
|
+
function waitReason(result) {
|
|
5
|
+
return result.log.replace(/^WAIT: /, "");
|
|
6
|
+
}
|
|
4
7
|
function waitSignature(result) {
|
|
5
8
|
const activity = result.activity ?? {
|
|
6
9
|
commitCount: 0,
|
|
@@ -36,7 +39,10 @@ export function writeWaitProgress(opts) {
|
|
|
36
39
|
const elapsedSeconds = Math.round(opts.elapsedMs / 1000);
|
|
37
40
|
const sleepSeconds = Math.round(opts.sleepMs / 1000);
|
|
38
41
|
if (!opts.quietStatus) {
|
|
39
|
-
|
|
42
|
+
const reason = waitReason(opts.result);
|
|
43
|
+
writeTickProgress(opts.tick, elapsedSeconds, opts.verbose
|
|
44
|
+
? `${reason} — sleeping ${sleepSeconds}s`
|
|
45
|
+
: `${reason}; next tick in ${sleepSeconds}s`);
|
|
40
46
|
return opts.lastWaitSignature;
|
|
41
47
|
}
|
|
42
48
|
const signature = waitSignature(opts.result);
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
-
import type { PollSummaryResult } from "../types.mts";
|
|
1
|
+
import type { PollSummaryItem, PollSummaryResult } from "../types.mts";
|
|
2
2
|
/** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
|
|
3
3
|
export declare function withPollSummaryInstructions(result: PollSummaryResult, mergeRequested: boolean): PollSummaryResult;
|
|
4
|
+
/** The projected summary, plus the idle layers when the stack plan can only wait on them. */
|
|
5
|
+
export declare function planPollSummary(result: PollSummaryResult, mergeRequested: boolean): {
|
|
6
|
+
result: PollSummaryResult;
|
|
7
|
+
idle?: PollSummaryItem[];
|
|
8
|
+
};
|
|
@@ -1,139 +1,183 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
2
3
|
import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
|
|
4
|
+
import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planBottomDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
|
|
5
|
+
import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
|
|
3
6
|
/** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
|
|
4
7
|
export function withPollSummaryInstructions(result, mergeRequested) {
|
|
8
|
+
return planPollSummary(result, mergeRequested).result;
|
|
9
|
+
}
|
|
10
|
+
/** The projected summary, plus the idle layers when the stack plan can only wait on them. */
|
|
11
|
+
export function planPollSummary(result, mergeRequested) {
|
|
5
12
|
if (result.selection.kind !== "stack") {
|
|
6
|
-
return { ...result, instructions: explicitInstructions(result) };
|
|
13
|
+
return { result: { ...result, instructions: explicitInstructions(result) } };
|
|
7
14
|
}
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const prs = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
|
|
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
|
+
const projected = {
|
|
46
|
+
...result,
|
|
47
|
+
prs: blocked.map((item) => {
|
|
48
|
+
if (item.state === "OPEN" && item.isInMergeQueue && item.action === "cancel") {
|
|
49
|
+
return {
|
|
50
|
+
...item,
|
|
51
|
+
action: "wait",
|
|
52
|
+
reasons: [...item.reasons, "already-in-merge-queue"],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (item.state === "CLOSED" && closedDependency(blocked, item.pr)) {
|
|
56
|
+
return {
|
|
57
|
+
...item,
|
|
58
|
+
action: "escalate",
|
|
59
|
+
reasons: [...item.reasons, "closed-unmerged-dependency"],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (item.state !== "OPEN" && item.state !== "MERGED") {
|
|
63
|
+
return {
|
|
64
|
+
...item,
|
|
65
|
+
action: "escalate",
|
|
66
|
+
reasons: [...item.reasons, "unverified-stack-state"],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return item;
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
const planned = planStack(projected, mergeRequested);
|
|
16
73
|
const instructions = [...planned.instructions];
|
|
17
|
-
if (result.quotaWarning && planned.action
|
|
74
|
+
if (result.quotaWarning && planned.action === "shepherd") {
|
|
18
75
|
instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
|
|
19
76
|
}
|
|
20
|
-
return {
|
|
77
|
+
return {
|
|
78
|
+
result: {
|
|
79
|
+
...projected,
|
|
80
|
+
reason: planned.action === "cancel"
|
|
81
|
+
? "all_terminal"
|
|
82
|
+
: planned.waiting
|
|
83
|
+
? result.reason === "timeout"
|
|
84
|
+
? "timeout"
|
|
85
|
+
: "waiting"
|
|
86
|
+
: "actionable",
|
|
87
|
+
stackMergeable: planned.stackMergeable,
|
|
88
|
+
nextAction: planned.action,
|
|
89
|
+
instructions,
|
|
90
|
+
},
|
|
91
|
+
...(planned.idle && { idle: planned.idle }),
|
|
92
|
+
};
|
|
21
93
|
}
|
|
22
94
|
function planStack(result, mergeRequested) {
|
|
23
95
|
const open = result.prs.filter((item) => item.state === "OPEN");
|
|
24
|
-
|
|
25
|
-
|
|
96
|
+
const gaps = result.stackAncestry ?? [];
|
|
97
|
+
const staleChildren = new Set(gaps.map((gap) => gap.childPr));
|
|
98
|
+
const stackMergeable = gaps.length === 0 && open.every(isStackLayerReady);
|
|
99
|
+
const work = splitStackWork(open.filter((item) => (!isStackLayerReady(item) || staleChildren.has(item.pr)) && item.action !== "escalate"), staleChildren);
|
|
100
|
+
const runnableCandidates = work.sessions.filter((item) => item.pollCommand);
|
|
101
|
+
const missingCommands = work.sessions.filter((item) => !item.pollCommand);
|
|
102
|
+
const agentWork = runnableCandidates.length > 0 || work.markReady.length > 0;
|
|
103
|
+
const drain = planBottomDrain(result, mergeRequested);
|
|
104
|
+
if (drain)
|
|
105
|
+
return drain;
|
|
106
|
+
const handoffs = findHumanHandoffs(result);
|
|
107
|
+
if (handoffs) {
|
|
108
|
+
const instructions = [];
|
|
109
|
+
appendAutonomousInstructions(instructions, runnableCandidates);
|
|
110
|
+
appendMarkReadyInstructions(instructions, work.markReady);
|
|
111
|
+
const stop = !agentWork;
|
|
112
|
+
appendHumanHandoffInstructions(instructions, handoffs, stop);
|
|
113
|
+
for (const item of missingCommands) {
|
|
114
|
+
instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. Ask for direction.`);
|
|
115
|
+
}
|
|
116
|
+
if (agentWork) {
|
|
117
|
+
instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
|
|
118
|
+
}
|
|
119
|
+
return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
|
|
26
120
|
}
|
|
27
|
-
|
|
28
|
-
const closedBelowOpen = result.prs.find((item) => item.state === "CLOSED" && positionOf(result, item.pr) < lastOpenPosition);
|
|
29
|
-
if (closedBelowOpen) {
|
|
121
|
+
if (open.length === 0) {
|
|
30
122
|
return {
|
|
31
|
-
action: "
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"2. Ask the stack owner which recovery path to take, then rerun the same aggregate `--stack` selector after the stack is repaired.",
|
|
35
|
-
],
|
|
123
|
+
action: "cancel",
|
|
124
|
+
stackMergeable: true,
|
|
125
|
+
instructions: ["1. Stop — every stack layer is merged."],
|
|
36
126
|
};
|
|
37
127
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"2. After GitHub completes the stack merge and updates the remaining branches, rerun the same aggregate `--stack` selector. If an ancestry mismatch remains, follow the rebase instructions returned then.",
|
|
51
|
-
],
|
|
52
|
-
};
|
|
128
|
+
if (!stackMergeable) {
|
|
129
|
+
if (missingCommands.length > 0) {
|
|
130
|
+
const instructions = [];
|
|
131
|
+
appendAutonomousInstructions(instructions, runnableCandidates);
|
|
132
|
+
appendMarkReadyInstructions(instructions, work.markReady);
|
|
133
|
+
for (const item of missingCommands) {
|
|
134
|
+
instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${agentWork ? "After autonomous shepherding, ask" : "Stop and ask"} for direction.`);
|
|
135
|
+
}
|
|
136
|
+
if (agentWork) {
|
|
137
|
+
instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
|
|
138
|
+
}
|
|
139
|
+
return { action: agentWork ? "shepherd" : "escalate", stackMergeable: false, instructions };
|
|
53
140
|
}
|
|
54
|
-
if (
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
141
|
+
if (!agentWork)
|
|
142
|
+
return idleWaitPlan(work.idle);
|
|
143
|
+
const instructions = [];
|
|
144
|
+
appendAutonomousInstructions(instructions, work.sessions);
|
|
145
|
+
appendMarkReadyInstructions(instructions, work.markReady);
|
|
146
|
+
instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
|
|
147
|
+
return { action: "shepherd", stackMergeable: false, instructions };
|
|
61
148
|
}
|
|
62
|
-
if (
|
|
149
|
+
if (open.some((item) => item.isInMergeQueue)) {
|
|
63
150
|
return {
|
|
64
|
-
action: "
|
|
151
|
+
action: "wait",
|
|
152
|
+
stackMergeable: true,
|
|
153
|
+
waiting: true,
|
|
65
154
|
instructions: [
|
|
66
|
-
|
|
67
|
-
"2. Rebase the upstack branches onto that parent with `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten branches with `gh stack push`.",
|
|
68
|
-
"3. Rerun the same aggregate `--stack` selector and follow the next returned action.",
|
|
155
|
+
"1. The stack is in the merge queue. Recheck at the configured polling cadence; finish only after every layer is merged, and route any ejected layer to its one-PR session.",
|
|
69
156
|
],
|
|
70
157
|
};
|
|
71
158
|
}
|
|
72
|
-
|
|
73
|
-
if (behind)
|
|
74
|
-
return rebaseWholeStack(result, behind);
|
|
75
|
-
if (firstWork)
|
|
76
|
-
return pollOneLayer(firstWork);
|
|
77
|
-
if (!mergeRequested && open.every((item) => item.action === "cancel")) {
|
|
159
|
+
if (!mergeRequested && result.prs.every((item) => item.action === "cancel")) {
|
|
78
160
|
return {
|
|
79
161
|
action: "cancel",
|
|
80
|
-
|
|
162
|
+
stackMergeable: true,
|
|
163
|
+
instructions: ["1. Stop — every stack layer is terminal or fully READY."],
|
|
81
164
|
};
|
|
82
165
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
action: "fix_code",
|
|
88
|
-
instructions: [
|
|
89
|
-
`1. GitHub reports PR #${behind.pr} is behind its base \`${behind.baseRefName}\`. From a clean checkout of \`${result.repo}\`, check out its stack branch \`${behind.headRefName}\`.`,
|
|
90
|
-
"2. Rebase that native stack from its trunk with `gh stack rebase`, resolving any conflicts.",
|
|
91
|
-
"3. Push the updated stack with `gh stack push` and rerun the same aggregate `--stack` selector.",
|
|
92
|
-
],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
function readyLowerStackTarget(open, gaps) {
|
|
96
|
-
const mismatchedChildren = new Set(gaps.map((gap) => gap.childPr));
|
|
97
|
-
let target;
|
|
98
|
-
for (const item of open) {
|
|
99
|
-
if (mismatchedChildren.has(item.pr) || item.action !== "merge")
|
|
100
|
-
break;
|
|
101
|
-
target = item;
|
|
102
|
-
}
|
|
103
|
-
return target;
|
|
104
|
-
}
|
|
105
|
-
function positionOf(result, pr) {
|
|
106
|
-
return result.prs.find((item) => item.pr === pr)?.stack?.position ?? Number.MAX_SAFE_INTEGER;
|
|
107
|
-
}
|
|
108
|
-
function pollOneLayer(item) {
|
|
109
|
-
if (!item.pollCommand) {
|
|
166
|
+
if (!mergeRequested) {
|
|
167
|
+
const instructions = [];
|
|
168
|
+
appendAutonomousInstructions(instructions, open.filter((item) => item.action !== "cancel"));
|
|
169
|
+
instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
|
|
110
170
|
return {
|
|
111
|
-
action: "
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
],
|
|
171
|
+
action: "shepherd",
|
|
172
|
+
stackMergeable,
|
|
173
|
+
instructions,
|
|
115
174
|
};
|
|
116
175
|
}
|
|
117
|
-
return
|
|
118
|
-
action: item.action,
|
|
119
|
-
instructions: [
|
|
120
|
-
`1. Work on the lowest actionable layer, PR #${item.pr}: run \`${item.pollCommand}\`.`,
|
|
121
|
-
"2. Follow that one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
|
|
122
|
-
"3. Rerun the aggregate `--stack` selector before acting on a higher layer.",
|
|
123
|
-
],
|
|
124
|
-
};
|
|
176
|
+
return retargetWaitPlan(open[0]);
|
|
125
177
|
}
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
buildQuotaAwareContinuation(result.quotaWarning, "1. This native stack is non-terminal. Before continuing,"),
|
|
132
|
-
],
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
return {
|
|
136
|
-
action: "wait",
|
|
137
|
-
instructions: ["1. Recheck this native stack after the lowest open layer changes state."],
|
|
138
|
-
};
|
|
178
|
+
function closedDependency(items, pr) {
|
|
179
|
+
const open = items.filter((item) => item.state === "OPEN");
|
|
180
|
+
const lastOpen = open.at(-1);
|
|
181
|
+
return Boolean(lastOpen &&
|
|
182
|
+
items.some((item) => item.pr === pr && item.state === "CLOSED" && stackPosition(item) < stackPosition(lastOpen)));
|
|
139
183
|
}
|