patchrelay 0.65.0 → 0.66.0
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/dist/build-info.json +3 -3
- package/dist/change-identity.js +46 -0
- package/dist/cli/args.js +1 -0
- package/dist/cli/commands/sequence-check.js +139 -0
- package/dist/cli/help.js +18 -0
- package/dist/cli/index.js +20 -0
- package/dist/db/migrations.js +4 -0
- package/dist/db/run-store.js +28 -0
- package/dist/db.js +1 -0
- package/dist/factory-state.js +14 -0
- package/dist/github-webhook-handler.js +12 -0
- package/dist/github-webhook-policy.js +7 -1
- package/dist/github-webhook-sequence-backstop.js +94 -0
- package/dist/github-webhook-state-projector.js +59 -1
- package/dist/pr-sequencing.js +132 -0
- package/dist/prompting/patchrelay.js +14 -0
- package/dist/reactive-run-policy.js +7 -0
- package/dist/run-finalizer.js +83 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export function computeChangeIdentityFromWorktree(params) {
|
|
3
|
+
const cwd = params.worktreePath;
|
|
4
|
+
const baseSha = resolveSha(cwd, params.baseRef);
|
|
5
|
+
const headSha = params.headSha
|
|
6
|
+
? resolveSha(cwd, params.headSha)
|
|
7
|
+
: resolveSha(cwd, "HEAD");
|
|
8
|
+
if (!baseSha || !headSha)
|
|
9
|
+
return {};
|
|
10
|
+
const patchId = computePatchId(cwd, baseSha, headSha);
|
|
11
|
+
const integrationTreeId = computeIntegrationTreeId(cwd, baseSha, headSha);
|
|
12
|
+
return {
|
|
13
|
+
...(patchId ? { patchId } : {}),
|
|
14
|
+
...(integrationTreeId ? { integrationTreeId } : {}),
|
|
15
|
+
baseSha,
|
|
16
|
+
headSha,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function resolveSha(cwd, ref) {
|
|
20
|
+
const result = spawnSync("git", ["-C", cwd, "rev-parse", ref], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
});
|
|
23
|
+
if (result.status !== 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const sha = result.stdout.trim();
|
|
26
|
+
return sha || undefined;
|
|
27
|
+
}
|
|
28
|
+
function computePatchId(cwd, base, head) {
|
|
29
|
+
// Pipe `git diff` through `git patch-id --stable`. Use sh -c to keep
|
|
30
|
+
// the pipeline atomic and avoid plumbing stdio between two spawns.
|
|
31
|
+
const result = spawnSync("sh", ["-c", `git -C ${shellQuote(cwd)} diff ${shellQuote(base)}..${shellQuote(head)} | git patch-id --stable`], { encoding: "utf8" });
|
|
32
|
+
if (result.status !== 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
const first = result.stdout.split(/\s+/, 1)[0]?.trim();
|
|
35
|
+
return first ? first : undefined;
|
|
36
|
+
}
|
|
37
|
+
function computeIntegrationTreeId(cwd, base, head) {
|
|
38
|
+
const result = spawnSync("git", ["-C", cwd, "merge-tree", "--write-tree", "--no-messages", base, head], { encoding: "utf8" });
|
|
39
|
+
if (result.status !== 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
const tree = result.stdout.trim().split(/\s+/, 1)[0];
|
|
42
|
+
return tree || undefined;
|
|
43
|
+
}
|
|
44
|
+
function shellQuote(value) {
|
|
45
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
46
|
+
}
|
package/dist/cli/args.js
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectStackingTarget } from "../../pr-sequencing.js";
|
|
4
|
+
import { CliUsageError } from "../errors.js";
|
|
5
|
+
import { formatJson } from "../formatters/json.js";
|
|
6
|
+
import { writeOutput } from "../output.js";
|
|
7
|
+
export async function handleSequenceCheckCommand(params) {
|
|
8
|
+
if (params.commandArgs.length > 0) {
|
|
9
|
+
throw new CliUsageError(`Unexpected argument for sequence-check: ${params.commandArgs[0]}`, "sequence-check");
|
|
10
|
+
}
|
|
11
|
+
const cwd = params.cwd ?? process.cwd();
|
|
12
|
+
const baseFlag = params.parsed.flags.get("base");
|
|
13
|
+
const overrideBase = typeof baseFlag === "string" ? baseFlag.trim() : "";
|
|
14
|
+
const self = params.selfProvider
|
|
15
|
+
? params.selfProvider()
|
|
16
|
+
: resolveSelf(cwd, overrideBase || undefined);
|
|
17
|
+
if (!self) {
|
|
18
|
+
writeOutput(params.stderr, "sequence-check: not inside a git work tree, or unable to resolve HEAD\n");
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
const candidates = params.candidatesProvider
|
|
22
|
+
? params.candidatesProvider()
|
|
23
|
+
: collectCandidates(params.data.db, self.branch);
|
|
24
|
+
const probe = params.gitProbe ?? cliGitProbe(cwd);
|
|
25
|
+
const recommendation = await detectStackingTarget({
|
|
26
|
+
self,
|
|
27
|
+
candidates,
|
|
28
|
+
git: probe,
|
|
29
|
+
});
|
|
30
|
+
if (params.json) {
|
|
31
|
+
writeOutput(params.stdout, formatJson(recommendation));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
writeOutput(params.stdout, `${JSON.stringify(recommendation)}\n`);
|
|
35
|
+
}
|
|
36
|
+
writeOutput(params.stderr, formatHumanSummary(recommendation));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
function resolveSelf(cwd, overrideBase) {
|
|
40
|
+
const branchResult = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
if (branchResult.status !== 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const branch = branchResult.stdout.trim();
|
|
47
|
+
if (!branch || branch === "HEAD")
|
|
48
|
+
return undefined;
|
|
49
|
+
const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" });
|
|
50
|
+
if (headResult.status !== 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
const headSha = headResult.stdout.trim();
|
|
53
|
+
if (!headSha)
|
|
54
|
+
return undefined;
|
|
55
|
+
const baseRef = overrideBase ?? resolveDefaultBranchRef(cwd);
|
|
56
|
+
if (!baseRef)
|
|
57
|
+
return undefined;
|
|
58
|
+
return { branch, headSha, baseRef };
|
|
59
|
+
}
|
|
60
|
+
function resolveDefaultBranchRef(cwd) {
|
|
61
|
+
// Prefer the symbolic upstream of origin/HEAD; fall back to main.
|
|
62
|
+
const symbolic = spawnSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
});
|
|
66
|
+
if (symbolic.status === 0) {
|
|
67
|
+
const ref = symbolic.stdout.trim();
|
|
68
|
+
if (ref)
|
|
69
|
+
return ref;
|
|
70
|
+
}
|
|
71
|
+
for (const candidate of ["origin/main", "origin/master"]) {
|
|
72
|
+
const probe = spawnSync("git", ["rev-parse", "--verify", candidate], {
|
|
73
|
+
cwd,
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
});
|
|
76
|
+
if (probe.status === 0)
|
|
77
|
+
return candidate;
|
|
78
|
+
}
|
|
79
|
+
return "origin/main";
|
|
80
|
+
}
|
|
81
|
+
function collectCandidates(db, selfBranch) {
|
|
82
|
+
const issues = db.issues.listIssues();
|
|
83
|
+
const candidates = [];
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
for (const issue of issues) {
|
|
86
|
+
if (issue.factoryState !== "pr_open" && issue.factoryState !== "awaiting_queue")
|
|
87
|
+
continue;
|
|
88
|
+
if (!issue.branchName || !issue.prHeadSha || !issue.prNumber)
|
|
89
|
+
continue;
|
|
90
|
+
if (issue.branchName === selfBranch)
|
|
91
|
+
continue;
|
|
92
|
+
const queueAgeMs = issue.updatedAt
|
|
93
|
+
? Math.max(0, now - Date.parse(issue.updatedAt))
|
|
94
|
+
: undefined;
|
|
95
|
+
candidates.push({
|
|
96
|
+
prNumber: issue.prNumber,
|
|
97
|
+
branch: issue.branchName,
|
|
98
|
+
headSha: issue.prHeadSha,
|
|
99
|
+
...(issue.prReviewState ? { reviewState: issue.prReviewState } : {}),
|
|
100
|
+
...(issue.prCheckStatus ? { checkStatus: issue.prCheckStatus } : {}),
|
|
101
|
+
factoryState: issue.factoryState,
|
|
102
|
+
...(queueAgeMs !== undefined ? { queueAgeMs } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return candidates;
|
|
106
|
+
}
|
|
107
|
+
function cliGitProbe(cwd) {
|
|
108
|
+
return {
|
|
109
|
+
async changedFiles(baseRef, headSha) {
|
|
110
|
+
const result = spawnSync("git", ["diff", "--name-only", `${baseRef}...${headSha}`], { cwd, encoding: "utf8" });
|
|
111
|
+
if (result.status !== 0) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
return result.stdout
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line) => line.trim())
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
},
|
|
119
|
+
async hasConflict(headSha, candidateHeadSha) {
|
|
120
|
+
// `git merge-tree --write-tree` exits non-zero on conflict in
|
|
121
|
+
// modern git; with `--no-messages` it suppresses commit-msg
|
|
122
|
+
// suggestions. Use the auto-merge-base form (two operands).
|
|
123
|
+
const result = spawnSync("git", ["merge-tree", "--write-tree", "--no-messages", headSha, candidateHeadSha], { cwd, encoding: "utf8" });
|
|
124
|
+
return result.status !== 0;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function formatHumanSummary(recommendation) {
|
|
129
|
+
if (recommendation.recommendation === "open_pr_against_main") {
|
|
130
|
+
return `sequence-check: open PR against main — ${recommendation.reason}\n`;
|
|
131
|
+
}
|
|
132
|
+
return [
|
|
133
|
+
`sequence-check: rebase onto PR #${recommendation.parentPr} (${recommendation.parentBranch})`,
|
|
134
|
+
` reason: ${recommendation.reason}`,
|
|
135
|
+
` parent head: ${recommendation.parentHead}`,
|
|
136
|
+
"",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
export { path };
|
package/dist/cli/help.js
CHANGED
|
@@ -214,7 +214,25 @@ export function helpTextFor(topic) {
|
|
|
214
214
|
return issueHelpText();
|
|
215
215
|
case "service":
|
|
216
216
|
return serviceHelpText();
|
|
217
|
+
case "sequence-check":
|
|
218
|
+
return sequenceCheckHelpText();
|
|
217
219
|
default:
|
|
218
220
|
return rootHelpText();
|
|
219
221
|
}
|
|
220
222
|
}
|
|
223
|
+
function sequenceCheckHelpText() {
|
|
224
|
+
return [
|
|
225
|
+
"patchrelay sequence-check",
|
|
226
|
+
"",
|
|
227
|
+
"Detect whether the current branch should be stacked on an in-flight PR before",
|
|
228
|
+
"opening a new one. Run from inside the worktree right before `gh pr create`.",
|
|
229
|
+
"",
|
|
230
|
+
"Output: JSON recommendation on stdout. Either:",
|
|
231
|
+
" {\"recommendation\":\"open_pr_against_main\",\"reason\":\"…\"}",
|
|
232
|
+
" {\"recommendation\":\"rebase_onto\",\"parentPr\":509,\"parentBranch\":\"…\",…}",
|
|
233
|
+
"",
|
|
234
|
+
"Flags:",
|
|
235
|
+
" --base <ref> Override the base ref used for diff (default: origin/HEAD)",
|
|
236
|
+
" --json Emit pretty-printed JSON",
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { assertKnownFlags, hasHelpFlag, parseArgs, resolveCommand } from "./args
|
|
|
4
4
|
import { handleIssueCommand, } from "./commands/issues.js";
|
|
5
5
|
import { handleClusterCommand } from "./commands/cluster.js";
|
|
6
6
|
import { handleLinearCommand } from "./commands/linear.js";
|
|
7
|
+
import { handleSequenceCheckCommand } from "./commands/sequence-check.js";
|
|
7
8
|
import { handleRepoCommand } from "./commands/repo.js";
|
|
8
9
|
import { handleInitCommand, handleServiceCommand } from "./commands/setup.js";
|
|
9
10
|
import { CliUsageError } from "./errors.js";
|
|
@@ -24,6 +25,7 @@ function getCommandConfigProfile(command) {
|
|
|
24
25
|
case "cluster":
|
|
25
26
|
case "repo":
|
|
26
27
|
case "issue":
|
|
28
|
+
case "sequence-check":
|
|
27
29
|
return "cli";
|
|
28
30
|
default:
|
|
29
31
|
return "service";
|
|
@@ -87,6 +89,9 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
87
89
|
case "cluster":
|
|
88
90
|
assertKnownFlags(parsed, command, ["json"]);
|
|
89
91
|
return;
|
|
92
|
+
case "sequence-check":
|
|
93
|
+
assertKnownFlags(parsed, command, ["json", "base"]);
|
|
94
|
+
return;
|
|
90
95
|
case "init":
|
|
91
96
|
assertKnownFlags(parsed, command, ["force", "json", "public-base-url"]);
|
|
92
97
|
return;
|
|
@@ -349,6 +354,21 @@ export async function runCli(argv, options) {
|
|
|
349
354
|
const { handleWatchCommand } = await import("./commands/watch.js");
|
|
350
355
|
return await handleWatchCommand({ config, parsed });
|
|
351
356
|
}
|
|
357
|
+
if (command === "sequence-check") {
|
|
358
|
+
const issueData = await ensureIssueDataAccess(data, config);
|
|
359
|
+
if (!data) {
|
|
360
|
+
data = issueData;
|
|
361
|
+
ownsData = true;
|
|
362
|
+
}
|
|
363
|
+
return await handleSequenceCheckCommand({
|
|
364
|
+
commandArgs,
|
|
365
|
+
parsed,
|
|
366
|
+
json,
|
|
367
|
+
stdout,
|
|
368
|
+
stderr,
|
|
369
|
+
data: issueData,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
352
372
|
throw new Error(`Unknown command: ${command}`);
|
|
353
373
|
}
|
|
354
374
|
catch (error) {
|
package/dist/db/migrations.js
CHANGED
|
@@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
62
62
|
summary_json TEXT,
|
|
63
63
|
report_json TEXT,
|
|
64
64
|
failure_reason TEXT,
|
|
65
|
+
should_not_publish INTEGER NOT NULL DEFAULT 0,
|
|
65
66
|
started_at TEXT NOT NULL,
|
|
66
67
|
ended_at TEXT
|
|
67
68
|
);
|
|
@@ -297,6 +298,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
297
298
|
addColumnIfMissing(connection, "runs", "completion_check_why", "TEXT");
|
|
298
299
|
addColumnIfMissing(connection, "runs", "completion_check_recommended_reply", "TEXT");
|
|
299
300
|
addColumnIfMissing(connection, "runs", "completion_checked_at", "TEXT");
|
|
301
|
+
// Plan §4.4: hard publication-suppression flag for the
|
|
302
|
+
// mid-run-approval cancellation primitive.
|
|
303
|
+
addColumnIfMissing(connection, "runs", "should_not_publish", "INTEGER NOT NULL DEFAULT 0");
|
|
300
304
|
addColumnIfMissing(connection, "issues", "last_blocking_review_head_sha", "TEXT");
|
|
301
305
|
// Collapse awaiting_review into pr_open (state normalization)
|
|
302
306
|
connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
|
package/dist/db/run-store.js
CHANGED
|
@@ -102,6 +102,34 @@ export class RunStore {
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
// Plan §4.4: flag a still-running run as superseded. We deliberately
|
|
106
|
+
// do NOT change `status` here — the Codex turn must finish naturally
|
|
107
|
+
// so the notification handler can deliver `turn/completed` to the
|
|
108
|
+
// run-finalizer (run-notification-handler ignores any run whose status
|
|
109
|
+
// is not 'running'). The finalizer reads `should_not_publish` and
|
|
110
|
+
// routes to `releaseSupersededRun`, which is where the row finally
|
|
111
|
+
// moves to status='superseded'. Setting status here would orphan the
|
|
112
|
+
// run: notifications would be dropped, the issue's activeRunId would
|
|
113
|
+
// never be cleared, and the lease would stay held.
|
|
114
|
+
markSuperseded(runId, params) {
|
|
115
|
+
this.connection.prepare(`
|
|
116
|
+
UPDATE runs SET
|
|
117
|
+
should_not_publish = 1,
|
|
118
|
+
failure_reason = COALESCE(failure_reason, ?)
|
|
119
|
+
WHERE id = ?
|
|
120
|
+
AND status IN ('queued', 'running')
|
|
121
|
+
`).run(params.reason, runId);
|
|
122
|
+
const run = this.getRunById(runId);
|
|
123
|
+
if (!run)
|
|
124
|
+
return;
|
|
125
|
+
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
126
|
+
if (issue) {
|
|
127
|
+
this.syncIssueSessionFromIssue(issue, {
|
|
128
|
+
summaryText: params.reason,
|
|
129
|
+
lastRunType: run.runType,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
105
133
|
saveCompletionCheck(runId, params) {
|
|
106
134
|
this.connection.prepare(`
|
|
107
135
|
UPDATE runs SET
|
package/dist/db.js
CHANGED
|
@@ -289,6 +289,7 @@ function mapRunRow(row) {
|
|
|
289
289
|
...(row.summary_json !== null ? { summaryJson: String(row.summary_json) } : {}),
|
|
290
290
|
...(row.report_json !== null ? { reportJson: String(row.report_json) } : {}),
|
|
291
291
|
...(row.failure_reason !== null ? { failureReason: String(row.failure_reason) } : {}),
|
|
292
|
+
...(row.should_not_publish === 1 || row.should_not_publish === true ? { shouldNotPublish: true } : {}),
|
|
292
293
|
startedAt: String(row.started_at),
|
|
293
294
|
...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
|
|
294
295
|
};
|
package/dist/factory-state.js
CHANGED
|
@@ -43,6 +43,20 @@ const TRANSITION_RULES = [
|
|
|
43
43
|
{ event: "review_approved",
|
|
44
44
|
guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
|
|
45
45
|
to: "awaiting_queue" },
|
|
46
|
+
// Plan §4.4: mid-run approval cancellation. When an approval lands
|
|
47
|
+
// on the same head a `review_fix` run was launched against, the
|
|
48
|
+
// run's premise no longer holds — there is no "fix" to publish, the
|
|
49
|
+
// PR is already approved. We let the transition fire so factoryState
|
|
50
|
+
// reflects reality (awaiting_queue), and the observer in
|
|
51
|
+
// reactive-run-policy.ts supersedes the still-running Codex turn so
|
|
52
|
+
// it can't push a cosmetic patch-id-equivalent commit.
|
|
53
|
+
{ event: "review_approved",
|
|
54
|
+
guard: (s, ctx) => isOpen(s)
|
|
55
|
+
&& ctx.activeRunId !== undefined
|
|
56
|
+
&& ctx.activeRunType === "review_fix"
|
|
57
|
+
&& ctx.approvalHeadSha !== undefined
|
|
58
|
+
&& ctx.approvalHeadSha === ctx.activeRunSourceHeadSha,
|
|
59
|
+
to: "awaiting_queue" },
|
|
46
60
|
{ event: "review_changes_requested",
|
|
47
61
|
guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
|
|
48
62
|
to: "changes_requested" },
|
|
@@ -7,6 +7,7 @@ import { resolveGitHubWebhookIssue } from "./github-webhook-issue-resolution.js"
|
|
|
7
7
|
import { maybeCloseLatePublishedImplementationPr } from "./github-webhook-late-publication-guard.js";
|
|
8
8
|
import { projectGitHubWebhookState } from "./github-webhook-state-projector.js";
|
|
9
9
|
import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js";
|
|
10
|
+
import { maybeRunSequenceBackstop } from "./github-webhook-sequence-backstop.js";
|
|
10
11
|
import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
|
|
11
12
|
export class GitHubWebhookHandler {
|
|
12
13
|
config;
|
|
@@ -130,6 +131,17 @@ export class GitHubWebhookHandler {
|
|
|
130
131
|
failureContextResolver: this.failureContextResolver,
|
|
131
132
|
fetchImpl: this.fetchImpl,
|
|
132
133
|
});
|
|
134
|
+
if (event.triggerEvent === "pr_opened") {
|
|
135
|
+
await maybeRunSequenceBackstop({
|
|
136
|
+
db: this.db,
|
|
137
|
+
logger: this.logger,
|
|
138
|
+
...(this.feed ? { feed: this.feed } : {}),
|
|
139
|
+
event,
|
|
140
|
+
fetchImpl: this.fetchImpl,
|
|
141
|
+
}).catch((error) => {
|
|
142
|
+
this.logger.warn({ err: error }, "sequence-check backstop failed");
|
|
143
|
+
});
|
|
144
|
+
}
|
|
133
145
|
if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
|
|
134
146
|
await handleGitHubTerminalPrEvent({
|
|
135
147
|
config: this.config,
|
|
@@ -75,7 +75,7 @@ export function canClearFailureProvenance(issue, event, project) {
|
|
|
75
75
|
}
|
|
76
76
|
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
77
77
|
}
|
|
78
|
-
export function resolveGitHubFactoryStateForEvent(issue, event, project) {
|
|
78
|
+
export function resolveGitHubFactoryStateForEvent(issue, event, project, activeRun) {
|
|
79
79
|
if (event.triggerEvent === "pr_closed") {
|
|
80
80
|
return undefined;
|
|
81
81
|
}
|
|
@@ -89,10 +89,16 @@ export function resolveGitHubFactoryStateForEvent(issue, event, project) {
|
|
|
89
89
|
const failureSource = event.triggerEvent === "check_failed"
|
|
90
90
|
? (isQueueEvictionFailure(issue, event, project) ? "queue_eviction" : "branch_ci")
|
|
91
91
|
: undefined;
|
|
92
|
+
const approvalHeadSha = event.triggerEvent === "review_approved"
|
|
93
|
+
? (event.reviewCommitId ?? event.headSha)
|
|
94
|
+
: undefined;
|
|
92
95
|
const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
|
|
93
96
|
prReviewState: issue.prReviewState,
|
|
94
97
|
activeRunId: issue.activeRunId,
|
|
95
98
|
failureSource,
|
|
99
|
+
...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
|
|
100
|
+
...(activeRun?.sourceHeadSha ? { activeRunSourceHeadSha: activeRun.sourceHeadSha } : {}),
|
|
101
|
+
...(approvalHeadSha ? { approvalHeadSha } : {}),
|
|
96
102
|
});
|
|
97
103
|
if (resolved !== undefined) {
|
|
98
104
|
return resolved;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Plan §8.2: backstop for missed sequence-checks. When a PR is
|
|
2
|
+
// opened and its changed-file set overlaps with another in-flight
|
|
3
|
+
// PR's, surface an operator event so the agent can be re-prompted
|
|
4
|
+
// (or a human can intervene). The full merge-tree probe lives in
|
|
5
|
+
// the CLI command; the backstop is intentionally cheap — file-set
|
|
6
|
+
// overlap only — because the webhook handler has no worktree.
|
|
7
|
+
export async function maybeRunSequenceBackstop(params) {
|
|
8
|
+
const { db, logger, feed, event } = params;
|
|
9
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
10
|
+
if (event.triggerEvent !== "pr_opened")
|
|
11
|
+
return;
|
|
12
|
+
if (!event.repoFullName || event.prNumber === undefined)
|
|
13
|
+
return;
|
|
14
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
15
|
+
if (!token)
|
|
16
|
+
return;
|
|
17
|
+
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
18
|
+
if (!owner || !repo)
|
|
19
|
+
return;
|
|
20
|
+
const newPrFiles = await listChangedFiles(fetchImpl, token, owner, repo, event.prNumber).catch(() => undefined);
|
|
21
|
+
if (!newPrFiles || newPrFiles.size === 0)
|
|
22
|
+
return;
|
|
23
|
+
const candidates = db.issues
|
|
24
|
+
.listIssues()
|
|
25
|
+
.filter((issue) => (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue")
|
|
26
|
+
&& issue.prNumber !== undefined
|
|
27
|
+
&& issue.prNumber !== event.prNumber
|
|
28
|
+
&& issue.branchName !== undefined
|
|
29
|
+
&& issue.branchName !== event.branchName);
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
const candidateFiles = await listChangedFiles(fetchImpl, token, owner, repo, candidate.prNumber).catch(() => undefined);
|
|
32
|
+
if (!candidateFiles)
|
|
33
|
+
continue;
|
|
34
|
+
const overlap = intersect(newPrFiles, candidateFiles);
|
|
35
|
+
if (overlap.length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
logger.info({
|
|
38
|
+
event: "sequence_backstop_overlap_detected",
|
|
39
|
+
prNumber: event.prNumber,
|
|
40
|
+
candidatePrNumber: candidate.prNumber,
|
|
41
|
+
overlap: overlap.slice(0, 10),
|
|
42
|
+
}, "potential stack-target detected on pr_opened");
|
|
43
|
+
feed?.publish({
|
|
44
|
+
level: "warn",
|
|
45
|
+
kind: "github",
|
|
46
|
+
summary: `PR #${event.prNumber} may need to stack on PR #${candidate.prNumber} (overlapping files)`,
|
|
47
|
+
detail: `Overlapping files: ${overlap.slice(0, 5).join(", ")}${overlap.length > 5 ? "…" : ""}`,
|
|
48
|
+
...(candidate.issueKey ? { issueKey: candidate.issueKey } : {}),
|
|
49
|
+
...(candidate.projectId ? { projectId: candidate.projectId } : {}),
|
|
50
|
+
});
|
|
51
|
+
// First overlap is enough — the operator-facing signal does not
|
|
52
|
+
// need to enumerate every potential parent.
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function listChangedFiles(fetchImpl, token, owner, repo, prNumber) {
|
|
57
|
+
const result = new Set();
|
|
58
|
+
let page = 1;
|
|
59
|
+
// GitHub caps `pulls/{n}/files` at 3000 across pages of 100.
|
|
60
|
+
while (page <= 30) {
|
|
61
|
+
const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/files?per_page=100&page=${page}`, {
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${token}`,
|
|
64
|
+
Accept: "application/vnd.github+json",
|
|
65
|
+
"User-Agent": "patchrelay",
|
|
66
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok)
|
|
70
|
+
return undefined;
|
|
71
|
+
const payload = (await response.json());
|
|
72
|
+
if (!Array.isArray(payload))
|
|
73
|
+
return undefined;
|
|
74
|
+
for (const entry of payload) {
|
|
75
|
+
if (!entry || typeof entry !== "object")
|
|
76
|
+
continue;
|
|
77
|
+
const filename = entry.filename;
|
|
78
|
+
if (typeof filename === "string" && filename)
|
|
79
|
+
result.add(filename);
|
|
80
|
+
}
|
|
81
|
+
if (payload.length < 100)
|
|
82
|
+
break;
|
|
83
|
+
page += 1;
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
function intersect(a, b) {
|
|
88
|
+
const result = [];
|
|
89
|
+
for (const value of a) {
|
|
90
|
+
if (b.has(value))
|
|
91
|
+
result.push(value);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
@@ -33,7 +33,15 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
33
33
|
const queueEvictionCheck = isQueueEvictionFailure(issue, event, project);
|
|
34
34
|
if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
|
|
35
35
|
const afterMetadata = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
36
|
-
const
|
|
36
|
+
const activeRun = afterMetadata.activeRunId
|
|
37
|
+
? deps.db.runs.getRunById(afterMetadata.activeRunId)
|
|
38
|
+
: undefined;
|
|
39
|
+
const newState = resolveGitHubFactoryStateForEvent(afterMetadata, event, project, activeRun
|
|
40
|
+
? {
|
|
41
|
+
...(activeRun.runType ? { runType: activeRun.runType } : {}),
|
|
42
|
+
...(activeRun.sourceHeadSha ? { sourceHeadSha: activeRun.sourceHeadSha } : {}),
|
|
43
|
+
}
|
|
44
|
+
: undefined);
|
|
37
45
|
if (newState && newState !== afterMetadata.factoryState) {
|
|
38
46
|
deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
39
47
|
projectId: issue.projectId,
|
|
@@ -41,6 +49,20 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
41
49
|
factoryState: newState,
|
|
42
50
|
});
|
|
43
51
|
deps.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
52
|
+
// Plan §4.4: when the transition fired *because* an approval
|
|
53
|
+
// landed during a review_fix run on the same head (the
|
|
54
|
+
// mid-run-approval rule), the run's premise is gone. Mark it
|
|
55
|
+
// superseded and set the publication-suppression flag so the
|
|
56
|
+
// finalizer cannot push a cosmetic patch-id-equivalent commit.
|
|
57
|
+
maybeSupersedeActiveRun({
|
|
58
|
+
db: deps.db,
|
|
59
|
+
logger: deps.logger,
|
|
60
|
+
feed: deps.feed,
|
|
61
|
+
issue: afterMetadata,
|
|
62
|
+
newState,
|
|
63
|
+
event,
|
|
64
|
+
activeRun,
|
|
65
|
+
});
|
|
44
66
|
const transitionedIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
45
67
|
void emitGitHubLinearActivity({
|
|
46
68
|
linearProvider: deps.linearProvider,
|
|
@@ -96,6 +118,42 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
96
118
|
});
|
|
97
119
|
return freshIssue;
|
|
98
120
|
}
|
|
121
|
+
// Plan §4.4: when the mid-run-approval transition fires, the active
|
|
122
|
+
// review_fix run's premise no longer holds — there is no fix to
|
|
123
|
+
// publish. Mark it superseded and set the publication-suppression
|
|
124
|
+
// flag. The Codex turn may have produced output already; the
|
|
125
|
+
// finalizer reads `shouldNotPublish` and refuses to push.
|
|
126
|
+
function maybeSupersedeActiveRun(params) {
|
|
127
|
+
const { db, logger, feed, issue, newState, event, activeRun } = params;
|
|
128
|
+
if (event.triggerEvent !== "review_approved")
|
|
129
|
+
return;
|
|
130
|
+
if (newState !== "awaiting_queue")
|
|
131
|
+
return;
|
|
132
|
+
if (!activeRun)
|
|
133
|
+
return;
|
|
134
|
+
if (activeRun.runType !== "review_fix")
|
|
135
|
+
return;
|
|
136
|
+
const approvalHead = event.reviewCommitId ?? event.headSha;
|
|
137
|
+
if (!approvalHead || !activeRun.sourceHeadSha)
|
|
138
|
+
return;
|
|
139
|
+
if (approvalHead !== activeRun.sourceHeadSha)
|
|
140
|
+
return;
|
|
141
|
+
db.runs.markSuperseded(activeRun.id, {
|
|
142
|
+
reason: "approved on the same head; further publication suppressed",
|
|
143
|
+
});
|
|
144
|
+
logger.info({
|
|
145
|
+
issueKey: issue.issueKey,
|
|
146
|
+
runId: activeRun.id,
|
|
147
|
+
headSha: approvalHead,
|
|
148
|
+
}, "Superseded mid-run review_fix after approval landed on the same head");
|
|
149
|
+
feed?.publish({
|
|
150
|
+
level: "info",
|
|
151
|
+
kind: "agent",
|
|
152
|
+
summary: `Superseded review_fix run #${activeRun.id} — PR approved on the same head`,
|
|
153
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
154
|
+
...(issue.projectId ? { projectId: issue.projectId } : {}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
99
157
|
async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver) {
|
|
100
158
|
if (event.triggerEvent === "pr_merged") {
|
|
101
159
|
deps.db.issues.upsertIssue({
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Plan §8.2: detect when an outgoing PR would conflict with an
|
|
2
|
+
// already-in-flight PR and recommend stacking against it instead of
|
|
3
|
+
// opening against main. Pure logic — IO is injected via the
|
|
4
|
+
// `GitProbe` interface so tests can drive it deterministically and
|
|
5
|
+
// the CLI can wire it to `spawnSync` git invocations.
|
|
6
|
+
const DEFAULT_SKIP_LABELS = ["wip", "do-not-merge", "do-not-merge-before"];
|
|
7
|
+
export async function detectStackingTarget(params) {
|
|
8
|
+
const { self, candidates, git } = params;
|
|
9
|
+
const skipLabels = (params.skipLabels ?? DEFAULT_SKIP_LABELS).map((label) => label.trim().toLowerCase());
|
|
10
|
+
if (candidates.length === 0) {
|
|
11
|
+
return { recommendation: "open_pr_against_main", reason: "no in-flight PRs to stack on" };
|
|
12
|
+
}
|
|
13
|
+
// Step 1: file-overlap pre-filter. Compute self's changed files
|
|
14
|
+
// once, then keep candidates whose changed-file set intersects.
|
|
15
|
+
const ownFiles = new Set(await git.changedFiles(self.baseRef, self.headSha));
|
|
16
|
+
if (ownFiles.size === 0) {
|
|
17
|
+
return { recommendation: "open_pr_against_main", reason: "no files changed in current branch" };
|
|
18
|
+
}
|
|
19
|
+
const overlappingCandidates = [];
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
if (candidate.branch === self.branch || candidate.headSha === self.headSha)
|
|
22
|
+
continue;
|
|
23
|
+
if (hasSkipLabel(candidate, skipLabels))
|
|
24
|
+
continue;
|
|
25
|
+
let candidateFiles;
|
|
26
|
+
try {
|
|
27
|
+
candidateFiles = await git.changedFiles(self.baseRef, candidate.headSha);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const overlap = candidateFiles.filter((file) => ownFiles.has(file));
|
|
33
|
+
if (overlap.length > 0) {
|
|
34
|
+
overlappingCandidates.push({ candidate, overlap });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (overlappingCandidates.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
recommendation: "open_pr_against_main",
|
|
40
|
+
reason: "no overlapping in-flight PRs",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Step 2: real conflict probe via merge-tree on each surviving
|
|
44
|
+
// candidate. We score only those that actually conflict; trees
|
|
45
|
+
// that auto-merge (different lines of the same file) are skipped.
|
|
46
|
+
const conflicting = [];
|
|
47
|
+
for (const entry of overlappingCandidates) {
|
|
48
|
+
let conflict = false;
|
|
49
|
+
try {
|
|
50
|
+
conflict = await git.hasConflict(self.headSha, entry.candidate.headSha);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Treat probe failure as "no conflict known" — fall through to
|
|
54
|
+
// open against main rather than recommending a possibly wrong
|
|
55
|
+
// stack target.
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (conflict) {
|
|
59
|
+
conflicting.push(entry);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (conflicting.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
recommendation: "open_pr_against_main",
|
|
65
|
+
reason: "overlapping files but no real conflict on merge-tree probe",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Step 3: score by likelihood-to-land-first. Higher score wins.
|
|
69
|
+
const ranked = conflicting
|
|
70
|
+
.map((entry) => ({ ...entry, score: scoreCandidate(entry.candidate) }))
|
|
71
|
+
.sort((a, b) => {
|
|
72
|
+
if (b.score !== a.score)
|
|
73
|
+
return b.score - a.score;
|
|
74
|
+
// Tie: prefer older queue entry.
|
|
75
|
+
const ageA = a.candidate.queueAgeMs ?? 0;
|
|
76
|
+
const ageB = b.candidate.queueAgeMs ?? 0;
|
|
77
|
+
if (ageA !== ageB)
|
|
78
|
+
return ageB - ageA;
|
|
79
|
+
// Final tie: lowest PR number first (it was opened earlier).
|
|
80
|
+
return a.candidate.prNumber - b.candidate.prNumber;
|
|
81
|
+
});
|
|
82
|
+
const winner = ranked[0];
|
|
83
|
+
const reasonBits = [];
|
|
84
|
+
reasonBits.push(`conflict on ${formatFileList(winner.overlap)}`);
|
|
85
|
+
reasonBits.push(`PR #${winner.candidate.prNumber} ${describeReadiness(winner.candidate)}`);
|
|
86
|
+
return {
|
|
87
|
+
recommendation: "rebase_onto",
|
|
88
|
+
parentPr: winner.candidate.prNumber,
|
|
89
|
+
parentBranch: winner.candidate.branch,
|
|
90
|
+
parentHead: winner.candidate.headSha,
|
|
91
|
+
reason: reasonBits.join("; ") + ", expected to land first",
|
|
92
|
+
conflictingFiles: winner.overlap,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function scoreCandidate(candidate) {
|
|
96
|
+
const review = (candidate.reviewState ?? "").trim().toLowerCase();
|
|
97
|
+
const checks = (candidate.checkStatus ?? "").trim().toLowerCase();
|
|
98
|
+
const factory = (candidate.factoryState ?? "").trim().toLowerCase();
|
|
99
|
+
let score = 0;
|
|
100
|
+
// Already in the queue → most likely to land first.
|
|
101
|
+
if (factory === "awaiting_queue")
|
|
102
|
+
score += 100;
|
|
103
|
+
if (review === "approved")
|
|
104
|
+
score += 50;
|
|
105
|
+
if (checks === "success" || checks === "passed")
|
|
106
|
+
score += 25;
|
|
107
|
+
if (review === "changes_requested")
|
|
108
|
+
score -= 30;
|
|
109
|
+
return score;
|
|
110
|
+
}
|
|
111
|
+
function hasSkipLabel(candidate, skipLabels) {
|
|
112
|
+
const labels = (candidate.labels ?? []).map((label) => label.trim().toLowerCase());
|
|
113
|
+
return labels.some((label) => skipLabels.includes(label));
|
|
114
|
+
}
|
|
115
|
+
function describeReadiness(candidate) {
|
|
116
|
+
const review = (candidate.reviewState ?? "").trim().toLowerCase();
|
|
117
|
+
const checks = (candidate.checkStatus ?? "").trim().toLowerCase();
|
|
118
|
+
const factory = (candidate.factoryState ?? "").trim().toLowerCase();
|
|
119
|
+
if (factory === "awaiting_queue")
|
|
120
|
+
return "is in the merge queue";
|
|
121
|
+
if (review === "approved" && (checks === "success" || checks === "passed")) {
|
|
122
|
+
return "is approved + green";
|
|
123
|
+
}
|
|
124
|
+
if (review === "approved")
|
|
125
|
+
return "is approved";
|
|
126
|
+
return "is in flight";
|
|
127
|
+
}
|
|
128
|
+
function formatFileList(files) {
|
|
129
|
+
if (files.length <= 3)
|
|
130
|
+
return files.join(", ");
|
|
131
|
+
return `${files.slice(0, 3).join(", ")} (+${files.length - 3} more)`;
|
|
132
|
+
}
|
|
@@ -42,11 +42,18 @@ function buildPromptHeader(issue) {
|
|
|
42
42
|
: prContext.kind === "closed_pr_paused"
|
|
43
43
|
? `Previous PR: #${prContext.prNumber} (closed; redelegate to replace it)`
|
|
44
44
|
: undefined;
|
|
45
|
+
// Plan §4.2(b): surface the patch-id of the last patchrelay-attributed
|
|
46
|
+
// publish so the agent can compute its own diff's patch-id pre-push
|
|
47
|
+
// and skip the publish if the result is patch-id-equivalent.
|
|
48
|
+
const patchIdLine = issue.lastPublishedPatchId
|
|
49
|
+
? `Last published patch-id: ${issue.lastPublishedPatchId}`
|
|
50
|
+
: undefined;
|
|
45
51
|
return [
|
|
46
52
|
`Issue: ${issue.issueKey ?? issue.linearIssueId}`,
|
|
47
53
|
issue.title ? `Title: ${issue.title}` : undefined,
|
|
48
54
|
issue.branchName ? `Branch: ${issue.branchName}` : undefined,
|
|
49
55
|
prLine,
|
|
56
|
+
patchIdLine,
|
|
50
57
|
].filter(Boolean).join("\n");
|
|
51
58
|
}
|
|
52
59
|
function extractIssueSection(description, heading) {
|
|
@@ -538,6 +545,10 @@ function buildPublicationContract(runType, issueClass) {
|
|
|
538
545
|
"If this is code-delivery work, publish before stopping: commit, push the issue branch, and open or update the PR.",
|
|
539
546
|
"If the issue explicitly allows a non-PR outcome, complete that outcome clearly instead of inventing a PR.",
|
|
540
547
|
"",
|
|
548
|
+
"Right before `gh pr create`, run `patchrelay sequence-check` from the worktree.",
|
|
549
|
+
"If the JSON recommendation is `rebase_onto`, rebase the branch onto the named parent and pass `--base <parent_branch>` to `gh pr create`. Include the recommendation's reason in the PR body under a `Stacked on #NNN` header.",
|
|
550
|
+
"If the recommendation is `open_pr_against_main`, proceed with `gh pr create` against the default base.",
|
|
551
|
+
"",
|
|
541
552
|
...buildPrePushSelfReviewSection("new_pr", runType),
|
|
542
553
|
].join("\n");
|
|
543
554
|
}
|
|
@@ -548,6 +559,9 @@ function buildPublicationContract(runType, issueClass) {
|
|
|
548
559
|
"Do not open a new PR.",
|
|
549
560
|
"A PR-less stop is not a successful outcome for a repair run unless a genuine external blocker prevents any correct push.",
|
|
550
561
|
"",
|
|
562
|
+
"Before pushing, compute `git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable` and compare its first field to the `Last published patch-id` shown in the prompt header (if any).",
|
|
563
|
+
"If they match, do not push — finish the run as a no-op. Edit the PR body via `gh pr edit` instead if a textual update is needed.",
|
|
564
|
+
"",
|
|
551
565
|
...buildPrePushSelfReviewSection("existing_pr", runType),
|
|
552
566
|
].join("\n");
|
|
553
567
|
}
|
|
@@ -62,6 +62,13 @@ export class ReactiveRunPolicy {
|
|
|
62
62
|
if (!isRequestedChangesRunType(run.runType)) {
|
|
63
63
|
return undefined;
|
|
64
64
|
}
|
|
65
|
+
// Plan §4.4: a superseded run was deliberately cancelled mid-flight
|
|
66
|
+
// because the PR was approved on the same head. Demanding a new head
|
|
67
|
+
// would be wrong — there's nothing to push, and the publication-
|
|
68
|
+
// suppression flag is the contract that says so.
|
|
69
|
+
if (run.shouldNotPublish || run.status === "superseded") {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
65
72
|
if (!issue.prNumber || issue.prState !== "open") {
|
|
66
73
|
return undefined;
|
|
67
74
|
}
|
package/dist/run-finalizer.js
CHANGED
|
@@ -2,6 +2,7 @@ import { buildStageReport, countEventMethods } from "./run-reporting.js";
|
|
|
2
2
|
import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
3
|
import { handleNoPrCompletionCheck } from "./no-pr-completion-check.js";
|
|
4
4
|
import { resolveCompletedRunState } from "./run-completion-policy.js";
|
|
5
|
+
import { computeChangeIdentityFromWorktree } from "./change-identity.js";
|
|
5
6
|
function parseEventJson(eventJson) {
|
|
6
7
|
if (!eventJson)
|
|
7
8
|
return undefined;
|
|
@@ -131,10 +132,73 @@ export class RunFinalizer {
|
|
|
131
132
|
return undefined;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
135
|
+
// Plan §4.2(c): record the identity of the head we just published
|
|
136
|
+
// so subsequent runs can recognize a patch-id-equivalent re-push.
|
|
137
|
+
// Only fires when the current head SHA is observably different from
|
|
138
|
+
// the run's starting sourceHeadSha — a no-op publish would not
|
|
139
|
+
// advance the head.
|
|
140
|
+
maybeUpdateLastPublishedIdentity(run, issue) {
|
|
141
|
+
if (!issue.worktreePath || !issue.prHeadSha)
|
|
142
|
+
return;
|
|
143
|
+
if (run.sourceHeadSha && run.sourceHeadSha === issue.prHeadSha)
|
|
144
|
+
return;
|
|
145
|
+
if (issue.lastPublishedHeadSha === issue.prHeadSha)
|
|
146
|
+
return;
|
|
147
|
+
const identity = computeChangeIdentityFromWorktree({
|
|
148
|
+
worktreePath: issue.worktreePath,
|
|
149
|
+
baseRef: "origin/main",
|
|
150
|
+
headSha: issue.prHeadSha,
|
|
151
|
+
});
|
|
152
|
+
if (!identity.patchId && !identity.integrationTreeId)
|
|
153
|
+
return;
|
|
154
|
+
this.db.issues.upsertIssue({
|
|
155
|
+
projectId: issue.projectId,
|
|
156
|
+
linearIssueId: issue.linearIssueId,
|
|
157
|
+
...(identity.patchId ? { lastPublishedPatchId: identity.patchId } : {}),
|
|
158
|
+
...(identity.integrationTreeId ? { lastPublishedIntegrationTreeId: identity.integrationTreeId } : {}),
|
|
159
|
+
lastPublishedHeadSha: issue.prHeadSha,
|
|
160
|
+
});
|
|
161
|
+
this.logger.info({
|
|
162
|
+
issueKey: issue.issueKey,
|
|
163
|
+
prHeadSha: issue.prHeadSha,
|
|
164
|
+
patchId: identity.patchId,
|
|
165
|
+
}, "Recorded last-published change identity after run completion");
|
|
166
|
+
}
|
|
134
167
|
clearProgressAndRelease(run) {
|
|
135
168
|
this.linearSync.clearProgress(run.id);
|
|
136
169
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
137
170
|
}
|
|
171
|
+
// Plan §4.4: finalize a run that was superseded mid-flight. The
|
|
172
|
+
// status row was already moved to `superseded` by the trigger
|
|
173
|
+
// observer; this just makes sure the issue's activeRunId is
|
|
174
|
+
// cleared, the lease is released, and the operator sees a
|
|
175
|
+
// clean recap event. No publication, no follow-up enqueue —
|
|
176
|
+
// the approval that triggered supersedure already advanced the
|
|
177
|
+
// factoryState.
|
|
178
|
+
releaseSupersededRun(run, threadId, completedTurnId) {
|
|
179
|
+
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
180
|
+
this.db.runs.finishRun(run.id, {
|
|
181
|
+
status: "superseded",
|
|
182
|
+
threadId,
|
|
183
|
+
...(completedTurnId ? { turnId: completedTurnId } : {}),
|
|
184
|
+
failureReason: run.failureReason ?? "approved on the same head; further publication suppressed",
|
|
185
|
+
});
|
|
186
|
+
this.db.issues.upsertIssue({
|
|
187
|
+
projectId: run.projectId,
|
|
188
|
+
linearIssueId: run.linearIssueId,
|
|
189
|
+
activeRunId: null,
|
|
190
|
+
pendingRunType: null,
|
|
191
|
+
pendingRunContextJson: null,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
this.clearProgressAndRelease(run);
|
|
195
|
+
this.feed?.publish({
|
|
196
|
+
level: "info",
|
|
197
|
+
kind: "agent",
|
|
198
|
+
summary: `Run #${run.id} superseded — publication suppressed (approved on the same head)`,
|
|
199
|
+
...(run.projectId ? { projectId: run.projectId } : {}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
138
202
|
enqueuePendingWakeIfPresent(params) {
|
|
139
203
|
const wake = this.db.issueSessions.peekIssueSessionWake(params.run.projectId, params.run.linearIssueId);
|
|
140
204
|
if (!wake)
|
|
@@ -201,6 +265,19 @@ export class RunFinalizer {
|
|
|
201
265
|
}
|
|
202
266
|
async finalizeCompletedRun(params) {
|
|
203
267
|
const { run, issue, thread, threadId } = params;
|
|
268
|
+
// Plan §4.4: a run flagged shouldNotPublish was deliberately
|
|
269
|
+
// superseded mid-flight (the PR was approved on the same head
|
|
270
|
+
// while a review_fix run was still producing output). The Codex
|
|
271
|
+
// turn may have completed; the finalizer must NOT run any of
|
|
272
|
+
// the publication-verification policies — they all assume the
|
|
273
|
+
// run was supposed to publish, and would either fail it
|
|
274
|
+
// spuriously (`verifyReviewFixAdvancedHead`) or open new
|
|
275
|
+
// follow-up work. Just record the supersedure outcome and
|
|
276
|
+
// release the lease.
|
|
277
|
+
if (run.shouldNotPublish || run.status === "superseded") {
|
|
278
|
+
this.releaseSupersededRun(run, threadId, params.completedTurnId);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
204
281
|
const trackedIssue = this.db.issueToTrackedIssue(issue);
|
|
205
282
|
const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.runs.listThreadEvents(run.id)));
|
|
206
283
|
const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
@@ -268,6 +345,12 @@ export class RunFinalizer {
|
|
|
268
345
|
return;
|
|
269
346
|
}
|
|
270
347
|
const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
|
|
348
|
+
// Plan §4.2(c): post-hoc change-identity detection. When the run
|
|
349
|
+
// produced a new head SHA, compute and persist the patch-id and
|
|
350
|
+
// integration-tree-id so the next run's prompt rule can recognize
|
|
351
|
+
// a patch-id-equivalent re-push and skip the publish. Best-effort:
|
|
352
|
+
// any git error returns undefined and we leave the cache as-is.
|
|
353
|
+
this.maybeUpdateLastPublishedIdentity(run, refreshedIssue);
|
|
271
354
|
const postRunFollowUp = await this.completionPolicy.resolvePostRunFollowUp(run, refreshedIssue);
|
|
272
355
|
const postRunState = postRunFollowUp?.factoryState ?? resolveCompletedRunState(refreshedIssue, run);
|
|
273
356
|
const publicationRecapSummary = await this.generatePublicationRecap({
|