patchrelay 0.65.0 → 0.67.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.67.0",
4
+ "commit": "3f61030e2318",
5
+ "builtAt": "2026-05-05T14:42:27.010Z"
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) {
@@ -224,6 +224,10 @@ export class IssueStore {
224
224
  sets.push("last_published_head_sha = @lastPublishedHeadSha");
225
225
  values.lastPublishedHeadSha = params.lastPublishedHeadSha;
226
226
  }
227
+ if (params.parentPrBranch !== undefined) {
228
+ sets.push("parent_pr_branch = @parentPrBranch");
229
+ values.parentPrBranch = params.parentPrBranch;
230
+ }
227
231
  if (params.ciRepairAttempts !== undefined) {
228
232
  sets.push("ci_repair_attempts = @ciRepairAttempts");
229
233
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -264,6 +268,7 @@ export class IssueStore {
264
268
  last_queue_signal_at, last_queue_incident_json,
265
269
  last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
266
270
  last_published_patch_id, last_published_integration_tree_id, last_published_head_sha,
271
+ parent_pr_branch,
267
272
  ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at, orchestration_settle_until,
268
273
  updated_at
269
274
  ) VALUES (
@@ -278,6 +283,7 @@ export class IssueStore {
278
283
  @lastQueueSignalAt, @lastQueueIncidentJson,
279
284
  @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
280
285
  @lastPublishedPatchId, @lastPublishedIntegrationTreeId, @lastPublishedHeadSha,
286
+ @parentPrBranch,
281
287
  @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt, @orchestrationSettleUntil,
282
288
  @now
283
289
  )
@@ -336,6 +342,7 @@ export class IssueStore {
336
342
  lastPublishedPatchId: params.lastPublishedPatchId ?? null,
337
343
  lastPublishedIntegrationTreeId: params.lastPublishedIntegrationTreeId ?? null,
338
344
  lastPublishedHeadSha: params.lastPublishedHeadSha ?? null,
345
+ parentPrBranch: params.parentPrBranch ?? null,
339
346
  ciRepairAttempts: params.ciRepairAttempts ?? 0,
340
347
  queueRepairAttempts: params.queueRepairAttempts ?? 0,
341
348
  reviewFixAttempts: params.reviewFixAttempts ?? 0,
@@ -419,6 +426,16 @@ export class IssueStore {
419
426
  .all();
420
427
  return rows.map(mapIssueRow);
421
428
  }
429
+ // Plan §8.3: parent-of-child index. Given a parent's branch name,
430
+ // list every issue whose `parent_pr_branch` matches — i.e. PRs
431
+ // stacked on that parent. The index is hit on every
432
+ // `pr_synchronize` for a parent so it must stay cheap.
433
+ listIssuesWithParentBranch(branchName) {
434
+ const rows = this.connection
435
+ .prepare(`SELECT * FROM issues WHERE parent_pr_branch = ? AND factory_state NOT IN ('done', 'failed')`)
436
+ .all(branchName);
437
+ return rows.map(mapIssueRow);
438
+ }
422
439
  // Issues that are approved by review-quill but stuck in In Review
423
440
  // because branch CI is failing — the merge-steward never admits them.
424
441
  // Plan §6.2: surface this as IN_REVIEW_STUCK so an operator notices
@@ -731,6 +748,9 @@ export function mapIssueRow(row) {
731
748
  ...(row.last_published_head_sha !== null && row.last_published_head_sha !== undefined
732
749
  ? { lastPublishedHeadSha: String(row.last_published_head_sha) }
733
750
  : {}),
751
+ ...(row.parent_pr_branch !== null && row.parent_pr_branch !== undefined
752
+ ? { parentPrBranch: String(row.parent_pr_branch) }
753
+ : {}),
734
754
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
735
755
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
736
756
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
@@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS issues (
35
35
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
36
36
  queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
37
37
  orchestration_settle_until TEXT,
38
+ parent_pr_branch TEXT,
38
39
  updated_at TEXT NOT NULL,
39
40
  UNIQUE(project_id, linear_issue_id)
40
41
  );
@@ -62,6 +63,7 @@ CREATE TABLE IF NOT EXISTS runs (
62
63
  summary_json TEXT,
63
64
  report_json TEXT,
64
65
  failure_reason TEXT,
66
+ should_not_publish INTEGER NOT NULL DEFAULT 0,
65
67
  started_at TEXT NOT NULL,
66
68
  ended_at TEXT
67
69
  );
@@ -297,6 +299,9 @@ export function runPatchRelayMigrations(connection) {
297
299
  addColumnIfMissing(connection, "runs", "completion_check_why", "TEXT");
298
300
  addColumnIfMissing(connection, "runs", "completion_check_recommended_reply", "TEXT");
299
301
  addColumnIfMissing(connection, "runs", "completion_checked_at", "TEXT");
302
+ // Plan §4.4: hard publication-suppression flag for the
303
+ // mid-run-approval cancellation primitive.
304
+ addColumnIfMissing(connection, "runs", "should_not_publish", "INTEGER NOT NULL DEFAULT 0");
300
305
  addColumnIfMissing(connection, "issues", "last_blocking_review_head_sha", "TEXT");
301
306
  // Collapse awaiting_review into pr_open (state normalization)
302
307
  connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
@@ -341,6 +346,9 @@ export function runPatchRelayMigrations(connection) {
341
346
  addColumnIfMissing(connection, "issues", "last_published_patch_id", "TEXT");
342
347
  addColumnIfMissing(connection, "issues", "last_published_integration_tree_id", "TEXT");
343
348
  addColumnIfMissing(connection, "issues", "last_published_head_sha", "TEXT");
349
+ // Plan §8.3: parent-of-child index for stacked PRs.
350
+ addColumnIfMissing(connection, "issues", "parent_pr_branch", "TEXT");
351
+ connection.exec(`CREATE INDEX IF NOT EXISTS idx_issues_parent_pr_branch ON issues(parent_pr_branch);`);
344
352
  addColumnIfMissing(connection, "linear_installations", "health_status", "TEXT NOT NULL DEFAULT 'ok'");
345
353
  addColumnIfMissing(connection, "linear_installations", "health_reason", "TEXT");
346
354
  addColumnIfMissing(connection, "linear_installations", "health_updated_at", "TEXT");
@@ -418,6 +426,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
418
426
  last_published_patch_id TEXT,
419
427
  last_published_integration_tree_id TEXT,
420
428
  last_published_head_sha TEXT,
429
+ parent_pr_branch TEXT,
421
430
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
422
431
  queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
423
432
  review_fix_attempts INTEGER NOT NULL DEFAULT 0,
@@ -484,6 +493,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
484
493
  last_published_patch_id,
485
494
  last_published_integration_tree_id,
486
495
  last_published_head_sha,
496
+ parent_pr_branch,
487
497
  ci_repair_attempts,
488
498
  queue_repair_attempts,
489
499
  review_fix_attempts,
@@ -548,6 +558,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
548
558
  last_published_patch_id,
549
559
  last_published_integration_tree_id,
550
560
  last_published_head_sha,
561
+ parent_pr_branch,
551
562
  COALESCE(ci_repair_attempts, 0),
552
563
  COALESCE(queue_repair_attempts, 0),
553
564
  COALESCE(review_fix_attempts, 0),
@@ -564,6 +575,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
564
575
  CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
565
576
  CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
566
577
  CREATE INDEX IF NOT EXISTS idx_issues_branch ON issues(branch_name);
578
+ CREATE INDEX IF NOT EXISTS idx_issues_parent_pr_branch ON issues(parent_pr_branch);
567
579
  `);
568
580
  }
569
581
  finally {
@@ -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,8 @@ 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";
11
+ import { maybeFanChildRebaseWakes } from "./github-webhook-stack-coordination.js";
10
12
  import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
11
13
  export class GitHubWebhookHandler {
12
14
  config;
@@ -130,6 +132,30 @@ export class GitHubWebhookHandler {
130
132
  failureContextResolver: this.failureContextResolver,
131
133
  fetchImpl: this.fetchImpl,
132
134
  });
135
+ if (event.triggerEvent === "pr_opened") {
136
+ await maybeRunSequenceBackstop({
137
+ db: this.db,
138
+ logger: this.logger,
139
+ ...(this.feed ? { feed: this.feed } : {}),
140
+ event,
141
+ fetchImpl: this.fetchImpl,
142
+ }).catch((error) => {
143
+ this.logger.warn({ err: error }, "sequence-check backstop failed");
144
+ });
145
+ }
146
+ // Plan §8.3: parent-moved trigger. When a PR's head advances,
147
+ // any child PR stacked on it becomes stale relative to its
148
+ // declared base — enqueue a `branch_upkeep` run on each child
149
+ // so it rebases onto the new parent head.
150
+ if (event.triggerEvent === "pr_synchronize") {
151
+ maybeFanChildRebaseWakes({
152
+ db: this.db,
153
+ logger: this.logger,
154
+ ...(this.feed ? { feed: this.feed } : {}),
155
+ enqueueIssue: this.enqueueIssue,
156
+ event,
157
+ });
158
+ }
133
159
  if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
134
160
  await handleGitHubTerminalPrEvent({
135
161
  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
+ }
@@ -0,0 +1,42 @@
1
+ // Plan §8.3-8.4: when a parent PR's head moves (review-fix push,
2
+ // eviction repair, base-branch update), child PRs stacked on it
3
+ // become stale. Patchrelay treats this as a wake event for each
4
+ // matching child and enqueues a `branch_upkeep` run to rebase the
5
+ // child onto the new parent head.
6
+ export function maybeFanChildRebaseWakes(params) {
7
+ const { db, logger, feed, enqueueIssue, event } = params;
8
+ if (event.triggerEvent !== "pr_synchronize")
9
+ return;
10
+ if (!event.branchName)
11
+ return;
12
+ const children = db.issues.listIssuesWithParentBranch(event.branchName);
13
+ if (children.length === 0)
14
+ return;
15
+ for (const child of children) {
16
+ if (child.activeRunId !== undefined) {
17
+ // Child already has a run going; let it complete and the next
18
+ // reconcile cycle pick up the new parent state.
19
+ logger.debug({ parentBranch: event.branchName, childIssue: child.issueKey, childRunId: child.activeRunId }, "Skipping child-rebase wake — child has an active run");
20
+ continue;
21
+ }
22
+ db.issues.upsertIssue({
23
+ projectId: child.projectId,
24
+ linearIssueId: child.linearIssueId,
25
+ pendingRunType: "branch_upkeep",
26
+ });
27
+ enqueueIssue(child.projectId, child.linearIssueId);
28
+ logger.info({
29
+ parentBranch: event.branchName,
30
+ parentHeadSha: event.headSha,
31
+ childIssue: child.issueKey,
32
+ childPrNumber: child.prNumber,
33
+ }, "Enqueued branch_upkeep on stacked child after parent PR head moved");
34
+ feed?.publish({
35
+ level: "info",
36
+ kind: "github",
37
+ summary: `Parent PR head moved on ${event.branchName} — branch_upkeep queued for child PR #${child.prNumber ?? "?"}`,
38
+ ...(child.issueKey ? { issueKey: child.issueKey } : {}),
39
+ ...(child.projectId ? { projectId: child.projectId } : {}),
40
+ });
41
+ }
42
+ }
@@ -8,6 +8,12 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
8
8
  const failureContextResolver = deps.failureContextResolver ?? createGitHubFailureContextResolver();
9
9
  const ciSnapshotResolver = deps.ciSnapshotResolver ?? createGitHubCiSnapshotResolver();
10
10
  const immediateCheckStatus = deriveImmediatePrCheckStatus(issue, event, project);
11
+ // Plan §8.3: when a PR's base ref differs from the repo default,
12
+ // it's stacked on another open PR. Cache the parent branch so we
13
+ // can fan child-rebase wakes on parent's `pr_synchronize`. Clear
14
+ // the field when a base ref reverts to the default (e.g. parent
15
+ // landed and GitHub auto-retargeted) or when the PR closes.
16
+ const parentPrBranch = computeParentPrBranchUpdate(event, project);
11
17
  deps.db.issues.upsertIssue({
12
18
  projectId: issue.projectId,
13
19
  linearIssueId: issue.linearIssueId,
@@ -19,6 +25,7 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
19
25
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
20
26
  ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
21
27
  ...(linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
28
+ ...(parentPrBranch !== undefined ? { parentPrBranch } : {}),
22
29
  ...(event.reviewState === "changes_requested"
23
30
  ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
24
31
  : event.reviewState === "approved"
@@ -33,7 +40,15 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
33
40
  const queueEvictionCheck = isQueueEvictionFailure(issue, event, project);
34
41
  if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
35
42
  const afterMetadata = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
36
- const newState = resolveGitHubFactoryStateForEvent(afterMetadata, event, project);
43
+ const activeRun = afterMetadata.activeRunId
44
+ ? deps.db.runs.getRunById(afterMetadata.activeRunId)
45
+ : undefined;
46
+ const newState = resolveGitHubFactoryStateForEvent(afterMetadata, event, project, activeRun
47
+ ? {
48
+ ...(activeRun.runType ? { runType: activeRun.runType } : {}),
49
+ ...(activeRun.sourceHeadSha ? { sourceHeadSha: activeRun.sourceHeadSha } : {}),
50
+ }
51
+ : undefined);
37
52
  if (newState && newState !== afterMetadata.factoryState) {
38
53
  deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
39
54
  projectId: issue.projectId,
@@ -41,6 +56,20 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
41
56
  factoryState: newState,
42
57
  });
43
58
  deps.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
59
+ // Plan §4.4: when the transition fired *because* an approval
60
+ // landed during a review_fix run on the same head (the
61
+ // mid-run-approval rule), the run's premise is gone. Mark it
62
+ // superseded and set the publication-suppression flag so the
63
+ // finalizer cannot push a cosmetic patch-id-equivalent commit.
64
+ maybeSupersedeActiveRun({
65
+ db: deps.db,
66
+ logger: deps.logger,
67
+ feed: deps.feed,
68
+ issue: afterMetadata,
69
+ newState,
70
+ event,
71
+ activeRun,
72
+ });
44
73
  const transitionedIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
45
74
  void emitGitHubLinearActivity({
46
75
  linearProvider: deps.linearProvider,
@@ -96,6 +125,59 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
96
125
  });
97
126
  return freshIssue;
98
127
  }
128
+ // Plan §8.3: derive the cached parent-PR-branch state from a webhook
129
+ // event. Returns `undefined` to mean "no change" (event isn't a
130
+ // PR-shape event with a base ref); returns `null` to mean "clear the
131
+ // field" (PR closed, or base ref is now the repo default).
132
+ function computeParentPrBranchUpdate(event, project) {
133
+ if (event.triggerEvent === "pr_closed" || event.triggerEvent === "pr_merged") {
134
+ return null;
135
+ }
136
+ if (event.prBaseRef === undefined) {
137
+ return undefined;
138
+ }
139
+ const repoDefault = project?.github?.baseBranch ?? "main";
140
+ if (!event.prBaseRef || event.prBaseRef === repoDefault) {
141
+ return null;
142
+ }
143
+ return event.prBaseRef;
144
+ }
145
+ // Plan §4.4: when the mid-run-approval transition fires, the active
146
+ // review_fix run's premise no longer holds — there is no fix to
147
+ // publish. Mark it superseded and set the publication-suppression
148
+ // flag. The Codex turn may have produced output already; the
149
+ // finalizer reads `shouldNotPublish` and refuses to push.
150
+ function maybeSupersedeActiveRun(params) {
151
+ const { db, logger, feed, issue, newState, event, activeRun } = params;
152
+ if (event.triggerEvent !== "review_approved")
153
+ return;
154
+ if (newState !== "awaiting_queue")
155
+ return;
156
+ if (!activeRun)
157
+ return;
158
+ if (activeRun.runType !== "review_fix")
159
+ return;
160
+ const approvalHead = event.reviewCommitId ?? event.headSha;
161
+ if (!approvalHead || !activeRun.sourceHeadSha)
162
+ return;
163
+ if (approvalHead !== activeRun.sourceHeadSha)
164
+ return;
165
+ db.runs.markSuperseded(activeRun.id, {
166
+ reason: "approved on the same head; further publication suppressed",
167
+ });
168
+ logger.info({
169
+ issueKey: issue.issueKey,
170
+ runId: activeRun.id,
171
+ headSha: approvalHead,
172
+ }, "Superseded mid-run review_fix after approval landed on the same head");
173
+ feed?.publish({
174
+ level: "info",
175
+ kind: "agent",
176
+ summary: `Superseded review_fix run #${activeRun.id} — PR approved on the same head`,
177
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
178
+ ...(issue.projectId ? { projectId: issue.projectId } : {}),
179
+ });
180
+ }
99
181
  async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver) {
100
182
  if (event.triggerEvent === "pr_merged") {
101
183
  deps.db.issues.upsertIssue({
@@ -70,6 +70,7 @@ function normalizePullRequestEvent(payload, repoFullName) {
70
70
  prLabels: Array.isArray(pr.labels)
71
71
  ? pr.labels.map((label) => label?.name).filter((label) => typeof label === "string" && label.trim().length > 0)
72
72
  : undefined,
73
+ prBaseRef: pr.base.ref,
73
74
  };
74
75
  }
75
76
  function normalizePullRequestReviewEvent(payload, repoFullName) {
@@ -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.67.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {