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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.65.0",
4
- "commit": "1b180d2544aa",
5
- "builtAt": "2026-05-04T22:53:34.027Z"
3
+ "version": "0.66.0",
4
+ "commit": "552ba486bf93",
5
+ "builtAt": "2026-05-05T14:36:59.557Z"
6
6
  }
@@ -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
@@ -18,6 +18,7 @@ export const KNOWN_COMMANDS = new Set([
18
18
  "connect",
19
19
  "installations",
20
20
  "help",
21
+ "sequence-check",
21
22
  ]);
22
23
  export function parseArgs(argv) {
23
24
  const positionals = [];
@@ -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) {
@@ -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();
@@ -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
  };
@@ -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 newState = resolveGitHubFactoryStateForEvent(afterMetadata, event, project);
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
  }
@@ -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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {