@henryqw/pi-pr 6.2.14 → 9.0.2

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.
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
6
6
  import { markFeedbackHandled } from "./pr-feedback-attention.ts";
7
+ import { PrRun } from "./pr-run.ts";
7
8
  import {
8
9
  extensionConfigDir,
9
10
  readTextFileBounded,
@@ -50,7 +51,7 @@ import {
50
51
 
51
52
  export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
52
53
 
53
- const STATE_VERSION = 2;
54
+ const STATE_VERSION = 3;
54
55
  const STATE_FILE = "state.json";
55
56
  const LEDGER_NOTE_MAX_BYTES = 2 * 1024;
56
57
  const REPLY_METADATA_RESERVE_BYTES = 8 * 1024;
@@ -73,12 +74,17 @@ type SweepRunGuard = {
73
74
  fingerprint: string;
74
75
  };
75
76
  type SweepCheck = { command: string; args: string[] };
77
+ type PendingScope = { head: string; exactOutsidePaths: string[] };
78
+ type SweepValidation = { head: string; checks: SweepCheck[] };
76
79
  type SweepFinalProjection = {
77
80
  generation: number;
78
81
  contentFingerprint: string;
79
82
  items: Array<{ id: string; kind: FeedbackKind }>;
80
83
  threads: Array<{ id: string; isResolved: boolean }>;
81
84
  };
85
+ // Only a fresh start before recovery is written may cancel for safe rerouting.
86
+ export class StaleSweepStart extends Error {}
87
+
82
88
  export type SweepStatus = {
83
89
  phase: SweepPhase;
84
90
  guard: SweepRunGuard;
@@ -91,6 +97,9 @@ export type SweepStatus = {
91
97
  plan: { ledger: SweepLedgerEntry[]; ownedPaths: string[] } | null;
92
98
  approved: boolean;
93
99
  legacyRecovery: boolean;
100
+ pendingScope: PendingScope | null;
101
+ validation: SweepValidation | null;
102
+ legacyChecks: SweepCheck[] | null;
94
103
  projection: SweepFinalProjection | null;
95
104
  attempts: {
96
105
  commit: AttemptState;
@@ -119,7 +128,7 @@ type CommitAttempt = {
119
128
  head: string | null;
120
129
  };
121
130
  type SweepState = {
122
- version: 1 | 2;
131
+ version: 1 | 2 | 3;
123
132
  workflow: "pi-pr-comment-sweep";
124
133
  worktree: { id: string; root: string };
125
134
  epoch: number;
@@ -137,6 +146,8 @@ type SweepState = {
137
146
  approved: boolean;
138
147
  approvalGeneration: number | null;
139
148
  ownedPaths: string[];
149
+ pendingScope: PendingScope | null;
150
+ validation: SweepValidation | null;
140
151
  publicationHead: string | null;
141
152
  projection: SweepFinalProjection | null;
142
153
  attempts: {
@@ -157,6 +168,8 @@ export type PullRequestCommentSweepOptions = {
157
168
  loadCurrentPullRequest?: Load;
158
169
  newRunId?: () => string;
159
170
  pause?: (milliseconds: number) => Promise<void>;
171
+ run?: PrRun;
172
+ confirmLegacyChecks?: (head: string, checks: SweepCheck[]) => Promise<boolean>;
160
173
  };
161
174
  function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
162
175
  const actual = Object.keys(value).sort();
@@ -248,8 +261,8 @@ function recoveryMatchesRouteAuthority(state: SweepState, suppliedAuthority: Swe
248
261
  (state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown" || state.attempts.push.state === "applied") &&
249
262
  state.publicationHead
250
263
  ) permittedHeads.add(state.publicationHead);
251
- return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head,
252
- state.attempts.push.state === "applied"));
264
+ // The base tip belongs to frozen feedback, not recovery or publication identity.
265
+ return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head, true));
253
266
  }
254
267
 
255
268
  function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAuthority, head: string): boolean {
@@ -414,12 +427,13 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
414
427
  !isRecord(value.attempts) || !isRecord(value.attempts.push) || !isRecord(value.attempts.finalize)) {
415
428
  throw new Error("sweep recovery state is invalid");
416
429
  }
430
+ if ((value.version !== 1 && value.version !== 2 && value.version !== STATE_VERSION) || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
417
431
  exactKeys(value, [
418
432
  "version", "workflow", "worktree", "epoch", "runId", "phase", "authority", "original", "feedback",
419
433
  "ledger", ...(value.version === 1 && !("approved" in value) ? [] : ["approved"]),
420
434
  ...("approvalGeneration" in value ? ["approvalGeneration"] : []), "ownedPaths", "publicationHead", "projection", "attempts",
435
+ ...(value.version === STATE_VERSION ? ["pendingScope", "validation"] : []),
421
436
  ], "sweep recovery state");
422
- if ((value.version !== 1 && value.version !== STATE_VERSION) || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
423
437
  if (!(["triage", "recorded", "published", "refresh-pending", "refreshed", "resolving", "resolved"] as unknown[]).includes(value.phase)) {
424
438
  throw new Error("sweep recovery phase is invalid");
425
439
  }
@@ -480,8 +494,26 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
480
494
  state: attemptState(value.attempts.finalize.state, "finalize attempt state"),
481
495
  checks: parseChecks(value.attempts.finalize.checks),
482
496
  };
497
+ let pendingScope: PendingScope | null = null;
498
+ let validation: SweepValidation | null = null;
499
+ if (value.version === STATE_VERSION) {
500
+ if (value.pendingScope !== null) {
501
+ if (!isRecord(value.pendingScope)) throw new Error("pending scope is invalid");
502
+ exactKeys(value.pendingScope, ["head", "exactOutsidePaths"], "pending scope");
503
+ pendingScope = { head: requiredOid(value.pendingScope.head, "pending scope HEAD"), exactOutsidePaths: parseOwnedPaths(value.pendingScope.exactOutsidePaths) };
504
+ if (value.phase !== "recorded" || push.state !== "none" || !pendingScope.exactOutsidePaths.length || pendingScope.exactOutsidePaths.some((path) => ownedPaths.includes(path))) throw new Error("pending scope is inconsistent");
505
+ }
506
+ if (value.validation !== null) {
507
+ if (!isRecord(value.validation)) throw new Error("validation is invalid");
508
+ exactKeys(value.validation, ["head", "checks"], "validation");
509
+ validation = { head: requiredOid(value.validation.head, "validated HEAD"), checks: parseChecks(value.validation.checks) };
510
+ if (pendingScope || !ledger) throw new Error("validation is inconsistent");
511
+ }
512
+ }
483
513
  const state: SweepState = {
484
- version: value.version as 1 | 2,
514
+ version: value.version as 1 | 2 | 3,
515
+ pendingScope,
516
+ validation,
485
517
  workflow: "pi-pr-comment-sweep",
486
518
  worktree: { id, root },
487
519
  epoch: integer(value.epoch, "sweep epoch"),
@@ -554,7 +586,10 @@ function status(state: SweepState): SweepStatus {
554
586
  ledgerComplete: state.ledger !== null,
555
587
  plan: state.ledger ? { ledger: structuredClone(state.ledger), ownedPaths: [...state.ownedPaths] } : null,
556
588
  approved: state.approved,
557
- legacyRecovery: state.version === 1,
589
+ legacyRecovery: state.version < STATE_VERSION,
590
+ pendingScope: structuredClone(state.pendingScope),
591
+ validation: structuredClone(state.validation),
592
+ legacyChecks: state.version < STATE_VERSION ? structuredClone(state.attempts.finalize.checks) : null,
558
593
  projection: state.projection ? structuredClone(state.projection) : null,
559
594
  attempts: {
560
595
  commit: state.attempts.commit?.state ?? "none",
@@ -625,6 +660,8 @@ export class PullRequestCommentSweep {
625
660
  private readonly load: Load;
626
661
  private readonly newRunId: () => string;
627
662
  private readonly pause?: (milliseconds: number) => Promise<void>;
663
+ private readonly run: PrRun;
664
+ private readonly confirmLegacyChecks?: PullRequestCommentSweepOptions["confirmLegacyChecks"];
628
665
 
629
666
  constructor(options: PullRequestCommentSweepOptions) {
630
667
  this.cwd = options.cwd;
@@ -635,6 +672,8 @@ export class PullRequestCommentSweep {
635
672
  this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
636
673
  this.newRunId = options.newRunId ?? randomUUID;
637
674
  this.pause = options.pause;
675
+ this.run = options.run ?? new PrRun();
676
+ this.confirmLegacyChecks = options.confirmLegacyChecks;
638
677
  }
639
678
 
640
679
  private options(extra: Partial<ExecOptions> = {}): ExecOptions {
@@ -711,8 +750,10 @@ export class PullRequestCommentSweep {
711
750
  }
712
751
 
713
752
  private async save(location: Awaited<ReturnType<PullRequestCommentSweep["location"]>>, state: SweepState): Promise<void> {
714
- const checked = parseState(state, location.root, location.id);
715
- const contents = `${JSON.stringify(checked)}\n`;
753
+ const { pendingScope: _pending, validation: _validation, ...legacy } = state;
754
+ const checked = parseState(state.version === STATE_VERSION ? state : legacy, location.root, location.id);
755
+ const { pendingScope: _checkedPending, validation: _checkedValidation, ...checkedLegacy } = checked;
756
+ const contents = `${JSON.stringify(checked.version === STATE_VERSION ? checked : checkedLegacy)}\n`;
716
757
  if (Buffer.byteLength(contents, "utf8") > SWEEP_RECOVERY_MAX_BYTES) {
717
758
  throw new Error(`Comment sweep recovery exceeds ${SWEEP_RECOVERY_MAX_BYTES} bytes`);
718
759
  }
@@ -760,6 +801,81 @@ export class PullRequestCommentSweep {
760
801
  return head;
761
802
  }
762
803
 
804
+ private requireRecorded(state: SweepState): void {
805
+ if (state.phase !== "recorded" || !state.ledger || !state.approved || state.attempts.push.state !== "none" ||
806
+ state.attempts.commit && state.attempts.commit.state !== "applied" || state.attempts.resolutions.length || state.attempts.finalize.state !== "none") {
807
+ throw new Error("Scope repair requires recorded unpublished recovery with no unresolved mutation");
808
+ }
809
+ }
810
+
811
+ /** Inspect scope only after reconciliation; never infer ownership from branch placement. */
812
+ private async planScope(state: SweepState): Promise<void> {
813
+ const head = await readHead(this.exec, this.options());
814
+ const dirty = await this.localPaths();
815
+ if (dirty.some((path) => !state.ownedPaths.includes(path))) throw new Error("Comment sweep found changes outside owned paths in the worktree");
816
+ const changed = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "--no-renames", "-z", `${state.original.head}..${head}`], this.options())).stdout, "Sweep committed paths");
817
+ const outside = changed.filter((path) => !state.ownedPaths.includes(path));
818
+ await this.requireOwnedLocalState({ ...state, ownedPaths: [...state.ownedPaths, ...outside] }, head);
819
+ if (outside.length) {
820
+ this.requireRecorded(state);
821
+ if (dirty.length) throw new Error("Scope review requires a clean worktree");
822
+ state.pendingScope = { head, exactOutsidePaths: validatePaths(outside, "Pending scope paths") };
823
+ state.validation = null;
824
+ } else state.pendingScope = null;
825
+ if (state.validation?.head !== head) state.validation = null;
826
+ await this.currentAuthority(state.authority, state.original.lease, true);
827
+ if (await readHead(this.exec, this.options()) !== head || !isDeepStrictEqual(await this.localPaths(), dirty)) throw new Error("Scope inspection local HEAD or worktree changed");
828
+ }
829
+
830
+ async adopt(guard: SweepRunGuard, headInput: string, outsideInput: string[]): Promise<SweepStatus> {
831
+ const head = requiredOid(headInput, "Reviewed scope HEAD");
832
+ const paths = parseOwnedPaths(outsideInput);
833
+ return await withWorktreeLock(this.cwd, async () => {
834
+ const location = await this.location();
835
+ const state = await this.loadState(location);
836
+ requireGuard(state, guard);
837
+ this.requireRecorded(state);
838
+ if (!state.pendingScope || state.pendingScope.head !== head || !isDeepStrictEqual([...state.pendingScope.exactOutsidePaths].sort(), [...paths].sort())) throw new Error("Adoption must match the exact pending scope HEAD and outside paths");
839
+ await this.planScope(state);
840
+ if (!state.pendingScope || state.pendingScope.head !== head || !isDeepStrictEqual([...state.pendingScope.exactOutsidePaths].sort(), [...paths].sort())) throw new Error("Reviewed committed scope changed before adoption");
841
+ state.ownedPaths = validatePaths([...state.ownedPaths, ...paths], "Sweep owned paths");
842
+ state.pendingScope = null;
843
+ state.validation = null;
844
+ await this.requireCleanPublication(state, head);
845
+ await this.currentAuthority(state.authority, state.original.lease, true);
846
+ await this.requireCleanPublication(state, head);
847
+ await this.save(location, state);
848
+ return status(state);
849
+ }, { agentDir: this.agentDir, signal: this.signal });
850
+ }
851
+
852
+ async validate(guard: SweepRunGuard, checksInput: SweepCheck[]): Promise<SweepStatus> {
853
+ const checks = parseChecks(checksInput);
854
+ return await withWorktreeLock(this.cwd, async () => {
855
+ const location = await this.location();
856
+ const state = await this.loadState(location);
857
+ requireGuard(state, guard);
858
+ this.requireRecorded(state);
859
+ if (state.pendingScope) throw new Error("Review and adopt pending scope before validation");
860
+ const head = await readHead(this.exec, this.options());
861
+ await this.requireCleanPublication(state, head);
862
+ await this.currentAuthority(state.authority, state.original.lease, true);
863
+ this.run.beginChecks("sweep", head, checks);
864
+ state.version = STATE_VERSION;
865
+ state.validation = null;
866
+ await this.save(location, state);
867
+ try {
868
+ await runChecked(this.exec, "git", ["diff", "--check", state.original.head, head], this.options());
869
+ for (const check of checks) await runChecked(this.exec, check.command, check.args, this.options());
870
+ await this.currentAuthority(state.authority, state.original.lease, true);
871
+ await this.requireCleanPublication(state, head);
872
+ } catch (error) { this.run.checksFailed(); throw error; }
873
+ state.validation = { head, checks };
874
+ await this.save(location, state);
875
+ return status(state);
876
+ }, { agentDir: this.agentDir, signal: this.signal });
877
+ }
878
+
763
879
  private async requireCleanPublication(state: SweepState, expectedHead: string): Promise<void> {
764
880
  if ((await this.localPaths()).length) throw new Error("Comment sweep requires a clean worktree");
765
881
  await this.requireOwnedLocalState(state, expectedHead);
@@ -795,6 +911,17 @@ export class PullRequestCommentSweep {
795
911
  };
796
912
  }
797
913
 
914
+ private async requireStartHead(authority: SweepAuthority): Promise<void> {
915
+ if ((await this.localPaths()).length) throw new Error("Comment sweep start requires a clean worktree");
916
+ const head = await readHead(this.exec, this.options());
917
+ if (head === authority.head.oid) return;
918
+ await this.currentAuthority(authority, authority.head.oid);
919
+ if (await isAncestor(this.exec, this.options(), authority.head.oid, head)) {
920
+ throw new StaleSweepStart("Local HEAD is ahead of the frozen PR head; cancelled before sweep recovery or publication");
921
+ }
922
+ throw new Error("Comment sweep start requires local HEAD to descend from the frozen PR head");
923
+ }
924
+
798
925
  async start(): Promise<SweepStatus> {
799
926
  return await withWorktreeLock(this.cwd, async () => {
800
927
  const location = await this.location();
@@ -802,17 +929,13 @@ export class PullRequestCommentSweep {
802
929
  if (!this.suppliedAuthority) throw new Error("Comment sweep start requires route authority");
803
930
  const authority = this.suppliedAuthority;
804
931
  if (authority.head.oid !== authority.target.remoteOid) throw new Error("Comment sweep requires PR head and remote lease to match");
805
- if ((await this.localPaths()).length || await readHead(this.exec, this.options()) !== authority.head.oid) {
806
- throw new Error("Comment sweep start requires a clean worktree at the PR head");
807
- }
932
+ await this.requireStartHead(authority);
808
933
  await this.currentAuthority(authority, authority.head.oid);
809
934
  const snapshot = await this.collect(authority, authority.head.oid);
810
935
  await this.currentAuthority(authority, authority.head.oid);
811
- if ((await this.localPaths()).length || await readHead(this.exec, this.options()) !== authority.head.oid) {
812
- throw new Error("Comment sweep authority changed during feedback fetch");
813
- }
936
+ await this.requireStartHead(authority);
814
937
  const state: SweepState = {
815
- version: 2,
938
+ version: STATE_VERSION,
816
939
  workflow: "pi-pr-comment-sweep",
817
940
  worktree: { id: location.id, root: location.root },
818
941
  epoch: 1,
@@ -830,6 +953,8 @@ export class PullRequestCommentSweep {
830
953
  approved: false,
831
954
  approvalGeneration: null,
832
955
  ownedPaths: [],
956
+ pendingScope: null,
957
+ validation: null,
833
958
  publicationHead: null,
834
959
  projection: null,
835
960
  attempts: {
@@ -868,7 +993,7 @@ export class PullRequestCommentSweep {
868
993
  if (state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown") {
869
994
  const attemptedHead = state.attempts.push.head;
870
995
  if (!attemptedHead) throw new Error("Attempted push has no captured head");
871
- await this.currentAuthority(state.authority, remote);
996
+ await this.currentAuthority(state.authority, remote, true);
872
997
  if (remote === attemptedHead) {
873
998
  state.attempts.push.state = "applied";
874
999
  state.phase = "published";
@@ -883,7 +1008,7 @@ export class PullRequestCommentSweep {
883
1008
  : state.original.lease;
884
1009
  remote = await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref);
885
1010
  if (remote !== expectedRemote) throw new Error("Comment sweep remote authority cannot be reconciled");
886
- await this.currentAuthority(state.authority, expectedRemote, state.attempts.push.state === "applied");
1011
+ await this.currentAuthority(state.authority, expectedRemote, true);
887
1012
 
888
1013
  const pending = state.attempts.resolutions.filter(({ state: attempt }) => attempt === "attempting" || attempt === "unknown");
889
1014
  if (pending.length > 1) throw new Error("Multiple unresolved mutation attempts cannot be reconciled");
@@ -944,16 +1069,16 @@ export class PullRequestCommentSweep {
944
1069
  state.attempts.push = { state: "none", head: null };
945
1070
  state.publicationHead = null;
946
1071
  }
947
- if (state.attempts.finalize.state === "blocked") state.attempts.finalize = { state: "none", checks: [] };
948
1072
  const published = state.attempts.push.state === "applied";
949
- if (published) state.version = 2;
1073
+ if (!published) state.version = STATE_VERSION;
950
1074
  const expectedRemote = published ? state.publicationHead! : state.original.lease;
951
- await this.currentAuthority(state.authority, expectedRemote, published);
952
- await this.requireOwnedLocalState(state, published ? expectedRemote : undefined);
1075
+ await this.currentAuthority(state.authority, expectedRemote, true);
953
1076
  if (state.ledger) {
954
1077
  state.approved = true;
955
1078
  state.approvalGeneration = state.feedback.generation;
956
1079
  }
1080
+ if (published) await this.requireCleanPublication(state, expectedRemote);
1081
+ else await this.planScope(state);
957
1082
  state.epoch += 1;
958
1083
  state.runId = safeRunId(this.newRunId());
959
1084
  await this.save(location, state);
@@ -978,7 +1103,7 @@ export class PullRequestCommentSweep {
978
1103
  if (state.phase === "triage" && state.ledger === null) {
979
1104
  if (ownedPathsInput === undefined) throw new Error("Initial comment sweep ledger requires ownedPaths");
980
1105
  await this.requireCleanPublication(state, state.original.head);
981
- await this.currentAuthority(state.authority, state.original.lease);
1106
+ await this.currentAuthority(state.authority, state.original.lease, true);
982
1107
  state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
983
1108
  state.approved = true;
984
1109
  state.approvalGeneration = state.feedback.generation;
@@ -1010,10 +1135,11 @@ export class PullRequestCommentSweep {
1010
1135
  if (state.phase !== "recorded" || !state.ledger || !state.approved || state.attempts.push.state !== "none") {
1011
1136
  throw new Error("Comment sweep is not ready to commit");
1012
1137
  }
1138
+ if (state.pendingScope) throw new Error("Review and adopt pending scope before committing");
1013
1139
  if (state.attempts.commit && state.attempts.commit.state !== "applied") {
1014
1140
  throw new Error("Comment sweep has an unreconciled commit; use resume");
1015
1141
  }
1016
- await this.currentAuthority(state.authority, state.original.lease);
1142
+ await this.currentAuthority(state.authority, state.original.lease, true);
1017
1143
  const beforeHead = await this.requireOwnedLocalState(state);
1018
1144
  const paths = await this.localPaths();
1019
1145
  if (paths.some((path) => !state.ownedPaths.includes(path))) throw new Error("Comment sweep found changes outside owned paths before staging");
@@ -1023,9 +1149,10 @@ export class PullRequestCommentSweep {
1023
1149
  const tree = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["write-tree"], this.options())).stdout, "Commit tree"), "commit tree");
1024
1150
  const attempt: CommitAttempt = { state: "attempting", beforeHead, tree, head: null };
1025
1151
  state.attempts.commit = attempt;
1152
+ state.validation = null;
1026
1153
  await this.save(location, state);
1027
1154
  try {
1028
- await this.currentAuthority(state.authority, state.original.lease);
1155
+ await this.currentAuthority(state.authority, state.original.lease, true);
1029
1156
  await this.requireOwnedLocalState(state, beforeHead);
1030
1157
  if (parseSingleOutputLine((await runChecked(this.exec, "git", ["write-tree"], this.options())).stdout, "Commit tree") !== tree) {
1031
1158
  throw new Error("Comment sweep index changed before commit");
@@ -1054,24 +1181,26 @@ export class PullRequestCommentSweep {
1054
1181
  if (state.phase !== "recorded" || !state.ledger || state.attempts.push.state !== "none" || !state.approved) {
1055
1182
  throw new Error("Comment sweep is not ready to publish");
1056
1183
  }
1184
+ if (state.pendingScope) throw new Error("Review and adopt pending scope before publication");
1057
1185
  if (state.attempts.commit && state.attempts.commit.state !== "applied") {
1058
1186
  throw new Error("Comment sweep has an unreconciled commit; use resume");
1059
1187
  }
1060
1188
  const head = await this.requireOwnedLocalState(state);
1061
1189
  if ((await this.localPaths()).length) throw new Error("Comment sweep publish requires a clean worktree; use the commit action for owned fixes first");
1062
- await this.currentAuthority(state.authority, state.original.lease);
1190
+ await this.currentAuthority(state.authority, state.original.lease, true);
1063
1191
  if (head === state.original.head) {
1064
1192
  state.publicationHead = head;
1065
1193
  state.attempts.push = { state: "applied", head };
1066
1194
  state.phase = "published";
1067
- state.version = 2;
1068
1195
  await this.save(location, state);
1069
1196
  return status(state);
1070
1197
  }
1198
+ if (!state.validation || state.validation.head !== head) throw new Error("Comment sweep requires validation on the exact clean HEAD before publication");
1199
+ this.run.beforePush(state.original.lease, head);
1071
1200
  if (!(await isAncestor(this.exec, this.options(), state.original.lease, head))) {
1072
1201
  throw new Error("Comment sweep push would not fast-forward the original lease");
1073
1202
  }
1074
- await this.currentAuthority(state.authority, state.original.lease);
1203
+ await this.currentAuthority(state.authority, state.original.lease, true);
1075
1204
  if (await readHead(this.exec, this.options()) !== head || (await this.localPaths()).length) {
1076
1205
  throw new Error("Comment sweep local HEAD or worktree changed before push");
1077
1206
  }
@@ -1087,10 +1216,10 @@ export class PullRequestCommentSweep {
1087
1216
  if (await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref) !== head) {
1088
1217
  throw new Error("Published remote ref did not match captured HEAD");
1089
1218
  }
1090
- await this.currentAuthority(state.authority, head);
1219
+ await this.currentAuthority(state.authority, head, true);
1091
1220
  state.attempts.push.state = "applied";
1092
1221
  state.phase = "published";
1093
- state.version = 2;
1222
+ this.run.observeRemote(head);
1094
1223
  await this.save(location, state);
1095
1224
  return status(state);
1096
1225
  } catch (error) {
@@ -1128,7 +1257,7 @@ export class PullRequestCommentSweep {
1128
1257
  state.approved = ledger !== null;
1129
1258
  state.approvalGeneration = ledger ? state.feedback.generation : null;
1130
1259
  state.projection = ledger ? buildProjection(state.feedback.generation, snapshot, ledger) : null;
1131
- state.attempts.finalize = { state: "none", checks: [] };
1260
+ if (state.version === STATE_VERSION) state.attempts.finalize = { state: "none", checks: [] };
1132
1261
  state.phase = ledger ? "refreshed" : "refresh-pending";
1133
1262
  await this.save(location, state);
1134
1263
  return status(state);
@@ -1299,13 +1428,19 @@ export class PullRequestCommentSweep {
1299
1428
  if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
1300
1429
  throw new Error("Comment sweep has unresolved or unknown mutation attempts");
1301
1430
  }
1302
- if (state.attempts.finalize.state !== "none" && state.attempts.finalize.state !== "applied") {
1303
- throw new Error("Comment sweep has an unreconciled finalization attempt; use resume");
1431
+ const legacy = state.version < STATE_VERSION;
1432
+ if (!legacy && state.attempts.finalize.state !== "none" && state.attempts.finalize.state !== "applied") throw new Error("Comment sweep has an unresolved finalization attempt");
1433
+ if (!legacy && checks.length) throw new Error("Run checks with validate before publication; v3 finalization runs no new checks");
1434
+ if (legacy && state.attempts.finalize.state !== "none" && !isDeepStrictEqual(state.attempts.finalize.checks, checks)) throw new Error("Legacy recovery requires exactly its saved checks");
1435
+ if (state.attempts.finalize.state === "unknown" || state.attempts.finalize.state === "attempting") {
1436
+ if (this.run.hasExecuted(state.publicationHead, checks) || !this.confirmLegacyChecks ||
1437
+ !await this.confirmLegacyChecks(state.publicationHead, checks)) throw new Error("Legacy check outcome unknown; a later /pr requires explicit confirmation of safe repeatability");
1304
1438
  }
1305
1439
  await this.freshProjection(state);
1306
1440
  const alreadyChecked = state.attempts.finalize.state === "applied" &&
1307
1441
  isDeepStrictEqual(state.attempts.finalize.checks, checks);
1308
- if (!alreadyChecked) {
1442
+ if (!alreadyChecked && legacy) {
1443
+ this.run.beginChecks("sweep-legacy", state.publicationHead, checks);
1309
1444
  state.attempts.finalize = { state: "attempting", checks };
1310
1445
  await this.save(location, state);
1311
1446
  for (const check of checks) {
@@ -1313,16 +1448,19 @@ export class PullRequestCommentSweep {
1313
1448
  try {
1314
1449
  result = await this.exec(check.command, check.args, this.options());
1315
1450
  } catch (error) {
1451
+ this.run.checksFailed();
1316
1452
  state.attempts.finalize.state = "unknown";
1317
1453
  await this.save(location, state);
1318
1454
  throw error;
1319
1455
  }
1320
1456
  if (result.killed) {
1457
+ this.run.checksFailed();
1321
1458
  state.attempts.finalize.state = "unknown";
1322
1459
  await this.save(location, state);
1323
1460
  throw new Error(`Finalization check was killed: ${check.command}`);
1324
1461
  }
1325
1462
  if (result.code !== 0) {
1463
+ this.run.checksFailed();
1326
1464
  state.attempts.finalize.state = "blocked";
1327
1465
  await this.save(location, state);
1328
1466
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
@@ -1,3 +1,4 @@
1
+ import type { PrRun } from "./pr-run.ts";
1
2
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
2
3
  import {
3
4
  branchTrackingRef,
@@ -75,6 +76,7 @@ type CreatePullRequestResult =
75
76
  | { kind: "published"; url: string };
76
77
 
77
78
  export type CreatePullRequestOptions = {
79
+ run?: PrRun;
78
80
  cwd: string;
79
81
  target: PullRequestTarget;
80
82
  signal?: AbortSignal;
@@ -162,6 +164,7 @@ function parseCreatedUrl(output: string, host: string, baseRepository: string):
162
164
  export class PullRequestCreator {
163
165
  readonly state: CreatePullRequestState = { phase: "unprepared" };
164
166
 
167
+ private readonly run?: PrRun;
165
168
  private readonly cwd: string;
166
169
  private readonly target: PullRequestTarget;
167
170
  private readonly noTarget: boolean;
@@ -173,6 +176,7 @@ export class PullRequestCreator {
173
176
 
174
177
  constructor(options: CreatePullRequestOptions) {
175
178
  this.cwd = options.cwd;
179
+ this.run = options.run;
176
180
  this.target = { ...options.target };
177
181
  this.noTarget = options.target.provenance === "inferred" && options.target.remoteOid === null;
178
182
  this.signal = options.signal;
@@ -359,6 +363,7 @@ export class PullRequestCreator {
359
363
  await this.freshNone();
360
364
  if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
361
365
  if (await this.requireCleanHead() !== head) throw new Error("PR creation cancelled: local HEAD changed before push");
366
+ this.run?.beforePush(original, head);
362
367
  this.state.publicationHead = head;
363
368
  this.state.phase = "blocked";
364
369
  await runChecked(this.exec, "git", [
@@ -368,6 +373,7 @@ export class PullRequestCreator {
368
373
  if (await readRemoteOid(this.exec, this.options(), this.target.fetchSource, this.target.ref) !== head) {
369
374
  throw new Error("Published remote ref did not match captured HEAD");
370
375
  }
376
+ this.run?.observeRemote(head);
371
377
  this.state.phase = "pushed";
372
378
  if (this.noTarget) {
373
379
  await this.configureNoTargetUpstream(head);
@@ -116,7 +116,7 @@ export type PullRequestObservation = {
116
116
  target: { repository: string; branch: string; remote: string; ref: string };
117
117
  };
118
118
 
119
- export type PullRequestLoadContext = { cwd: string; signal?: AbortSignal };
119
+ export type PullRequestLoadContext = { cwd: string; signal?: AbortSignal; rebaseBranch?: string };
120
120
 
121
121
  type PullRequestCreationPreflight = {
122
122
  head: string;
@@ -861,10 +861,11 @@ function conditions(
861
861
  ? "pending"
862
862
  : "ready";
863
863
  const behind = candidate.mergeStateStatus === "BEHIND";
864
+ const conflict = candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY";
864
865
  return {
865
866
  draft: candidate.isDraft,
866
867
  baseUpdateRequired: behind,
867
- conflict: candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY",
868
+ conflict,
868
869
  changesRequested: candidate.reviewDecision === "CHANGES_REQUESTED",
869
870
  unresolvedThreads,
870
871
  ci: ciStatus(candidate.checkStates),
@@ -872,6 +873,10 @@ function conditions(
872
873
  policy: candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "CLEAN"
873
874
  ? "ready"
874
875
  : "pending",
876
+ // A confirmed conflict on either field is definitive; the other field's UNKNOWN is not worth waiting for.
877
+ mergeability: !conflict && (candidate.mergeable === "UNKNOWN" || candidate.mergeStateStatus === "UNKNOWN")
878
+ ? "pending"
879
+ : "known",
875
880
  };
876
881
  }
877
882
 
@@ -1365,7 +1370,9 @@ async function readPushTarget(
1365
1370
  if (worktree.code !== 0) commandFailure("Check Git worktree", worktree);
1366
1371
  if (worktreeOutput !== "true\n") fail("Check Git worktree", "invalid response");
1367
1372
 
1368
- const branchResult = await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"]);
1373
+ const branchResult = context.rebaseBranch === undefined
1374
+ ? await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])
1375
+ : { stdout: `${context.rebaseBranch}\n` };
1369
1376
  if (branchResult.stdout === "") return { kind: "blocked", issue: "detached" };
1370
1377
  let branch: string;
1371
1378
  try {
@@ -6,6 +6,7 @@ import {
6
6
  runChecked,
7
7
  } from "./pr-execution.ts";
8
8
  import type { LocalMergeSafety } from "./pr-routing.ts";
9
+ import type { MergeMethod } from "./pr-run.ts";
9
10
 
10
11
  const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
11
12
 
@@ -23,6 +24,8 @@ type InspectedLocalMergeSafety = LocalMergeSafety & {
23
24
  type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
24
25
  pullRequestId: string;
25
26
  hostname: string;
27
+ /** Configured merge method; GitHub rejects a method the repository does not allow. */
28
+ mergeMethod?: MergeMethod;
26
29
  expectedBase: {
27
30
  repository: string;
28
31
  ref: string;
@@ -127,7 +130,7 @@ export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promis
127
130
  "-F",
128
131
  `expectedHeadOid=${input.expectedHead}`,
129
132
  "-F",
130
- "mergeMethod=SQUASH",
133
+ `mergeMethod=${(input.mergeMethod ?? "squash").toUpperCase()}`,
131
134
  ], { cwd: input.cwd });
132
135
  const output = merged.stdout.trim();
133
136
  if (!output) throw new Error("GitHub merge returned no output");