@henryqw/pi-pr 6.0.7 → 6.2.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.
@@ -3,6 +3,7 @@ import { realpath, rm } from "node:fs/promises";
3
3
  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
+ import { markFeedbackHandled } from "./pr-feedback-attention.ts";
6
7
  import {
7
8
  extensionConfigDir,
8
9
  readTextFileBounded,
@@ -17,7 +18,9 @@ import {
17
18
  parseFeedbackSnapshot,
18
19
  showFeedbackItem,
19
20
  resolvePullRequestThread,
21
+ replyToPullRequestThread,
20
22
  FEEDBACK_MAX_RECORDS,
23
+ FEEDBACK_SNAPSHOT_MAX_BYTES,
21
24
  type FeedbackItem,
22
25
  type FeedbackKind,
23
26
  type FeedbackSnapshot,
@@ -46,9 +49,10 @@ import {
46
49
 
47
50
  export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
48
51
 
49
- const STATE_VERSION = 1;
52
+ const STATE_VERSION = 2;
50
53
  const STATE_FILE = "state.json";
51
54
  const LEDGER_NOTE_MAX_BYTES = 2 * 1024;
55
+ const REPLY_METADATA_RESERVE_BYTES = 8 * 1024;
52
56
  const CHECK_MAX_COUNT = 32;
53
57
  const CHECK_ARGUMENTS_MAX_BYTES = 32 * 1024;
54
58
  const ATTEMPT_STATES = new Set<AttemptState>(["none", "attempting", "applied", "blocked", "unknown"]);
@@ -83,10 +87,13 @@ export type SweepStatus = {
83
87
  feedbackCount: number;
84
88
  feedback: Array<{ id: string; kind: FeedbackKind }>;
85
89
  ledgerComplete: boolean;
90
+ plan: { ledger: SweepLedgerEntry[]; ownedPaths: string[] } | null;
91
+ approved: boolean;
92
+ legacyRecovery: boolean;
86
93
  projection: SweepFinalProjection | null;
87
94
  attempts: {
88
95
  push: AttemptState;
89
- resolutions: Array<{ threadId: string; state: AttemptState }>;
96
+ resolutions: Array<{ threadId: string; step: "reply" | "resolve"; state: AttemptState }>;
90
97
  finalize: AttemptState;
91
98
  };
92
99
  };
@@ -96,12 +103,15 @@ type SweepAuthority = ReturnType<typeof authorityFromCurrent>;
96
103
  type ResolutionAttempt = {
97
104
  generation: number;
98
105
  threadId: string;
106
+ step: "reply" | "resolve";
107
+ body: string | null;
108
+ replyId: string | null;
99
109
  state: AttemptState;
100
110
  beforeFingerprint: string;
101
111
  afterFingerprint: string | null;
102
112
  };
103
113
  type SweepState = {
104
- version: 1;
114
+ version: 1 | 2;
105
115
  workflow: "pi-pr-comment-sweep";
106
116
  worktree: { id: string; root: string };
107
117
  epoch: number;
@@ -116,6 +126,8 @@ type SweepState = {
116
126
  snapshot: FeedbackSnapshot;
117
127
  };
118
128
  ledger: SweepLedgerEntry[] | null;
129
+ approved: boolean;
130
+ approvalGeneration: number | null;
119
131
  ownedPaths: string[];
120
132
  publicationHead: string | null;
121
133
  projection: SweepFinalProjection | null;
@@ -216,10 +228,10 @@ function parseAuthority(value: unknown): SweepAuthority {
216
228
  };
217
229
  }
218
230
 
219
- function sameLinkage(expected: SweepAuthority, current: SweepAuthority, remoteHead: string): boolean {
231
+ function sameLinkage(expected: SweepAuthority, current: SweepAuthority, remoteHead: string, allowBaseDrift = false): boolean {
220
232
  return expected.id === current.id && expected.number === current.number && expected.url === current.url &&
221
233
  expected.host === current.host && expected.base.repository === current.base.repository &&
222
- expected.base.ref === current.base.ref && expected.base.oid === current.base.oid &&
234
+ expected.base.ref === current.base.ref && (allowBaseDrift || expected.base.oid === current.base.oid) &&
223
235
  expected.head.repository === current.head.repository && expected.head.ref === current.head.ref &&
224
236
  expected.headFetchSource === current.headFetchSource && expected.target.branch === current.target.branch &&
225
237
  expected.target.remote === current.target.remote && expected.target.ref === current.target.ref &&
@@ -235,7 +247,8 @@ function recoveryMatchesRouteAuthority(state: SweepState, suppliedAuthority: Swe
235
247
  (state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown" || state.attempts.push.state === "applied") &&
236
248
  state.publicationHead
237
249
  ) permittedHeads.add(state.publicationHead);
238
- return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head));
250
+ return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head,
251
+ state.attempts.push.state === "applied"));
239
252
  }
240
253
 
241
254
  function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAuthority, head: string): boolean {
@@ -303,7 +316,7 @@ function parseChecks(value: unknown): SweepCheck[] {
303
316
  return checks;
304
317
  }
305
318
 
306
- function buildProjection(generation: number, snapshot: FeedbackSnapshot, ledger: SweepLedgerEntry[]): SweepFinalProjection {
319
+ function buildProjection(generation: number, snapshot: FeedbackSnapshot, ledger: SweepLedgerEntry[], resolveNonActionable = true, legacyChildren = false): SweepFinalProjection {
307
320
  const dispositions = new Map(ledger.map((entry) => [entry.id, entry]));
308
321
  return {
309
322
  generation,
@@ -312,7 +325,11 @@ function buildProjection(generation: number, snapshot: FeedbackSnapshot, ledger:
312
325
  left.id.localeCompare(right.id) || left.kind.localeCompare(right.kind)),
313
326
  threads: snapshot.reviewThreads.map((thread) => ({
314
327
  id: thread.id,
315
- isResolved: thread.isResolved || dispositions.get(thread.id)?.disposition === "addressed",
328
+ isResolved: thread.isResolved || (
329
+ (legacyChildren || !thread.comments.some((comment) => dispositions.get(comment.id)?.disposition === "blocked")) &&
330
+ (dispositions.get(thread.id)?.disposition === "addressed" ||
331
+ (resolveNonActionable && dispositions.get(thread.id)?.disposition === "non-actionable"))
332
+ ),
316
333
  })).sort((left, right) => left.id.localeCompare(right.id)),
317
334
  };
318
335
  }
@@ -352,12 +369,25 @@ function projectionMatches(snapshot: FeedbackSnapshot, projection: SweepFinalPro
352
369
  return isDeepStrictEqual(items, projection.items) && isDeepStrictEqual(threads, projection.threads);
353
370
  }
354
371
 
355
- function resolutionAttempt(value: unknown, index: number): ResolutionAttempt {
372
+ function resolutionAttempt(value: unknown, index: number, version: number): ResolutionAttempt {
356
373
  if (!isRecord(value)) throw new Error(`resolution attempt ${index + 1} is invalid`);
357
- exactKeys(value, ["generation", "threadId", "state", "beforeFingerprint", "afterFingerprint"], `resolution attempt ${index + 1}`);
374
+ const legacy = version === 1 && !("step" in value);
375
+ exactKeys(value, legacy
376
+ ? ["generation", "threadId", "state", "beforeFingerprint", "afterFingerprint"]
377
+ : ["generation", "threadId", "step", "body", ...("replyId" in value ? ["replyId"] : []), "state", "beforeFingerprint", "afterFingerprint"], `resolution attempt ${index + 1}`);
378
+ const step = legacy ? "resolve" : value.step;
379
+ const body = legacy ? null : value.body;
380
+ if (step !== "reply" && step !== "resolve") throw new Error(`resolution attempt ${index + 1} step is invalid`);
381
+ if (step === "reply" ? typeof body !== "string" || !body : body !== null) {
382
+ throw new Error(`resolution attempt ${index + 1} body is invalid`);
383
+ }
384
+ if (step !== "reply" && value.replyId != null) throw new Error(`resolution attempt ${index + 1} reply ID is invalid`);
358
385
  return {
359
386
  generation: integer(value.generation, `resolution attempt ${index + 1} generation`),
360
387
  threadId: requiredText(value.threadId, `resolution attempt ${index + 1} thread ID`),
388
+ step,
389
+ body: body as string | null,
390
+ replyId: value.replyId === undefined || value.replyId === null ? null : requiredText(value.replyId, `resolution attempt ${index + 1} reply ID`),
361
391
  state: attemptState(value.state, `resolution attempt ${index + 1} state`),
362
392
  beforeFingerprint: sha256(requiredText(value.beforeFingerprint, `resolution attempt ${index + 1} before fingerprint`), `resolution attempt ${index + 1} before fingerprint`),
363
393
  afterFingerprint: value.afterFingerprint === null
@@ -373,9 +403,10 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
373
403
  }
374
404
  exactKeys(value, [
375
405
  "version", "workflow", "worktree", "epoch", "runId", "phase", "authority", "original", "feedback",
376
- "ledger", "ownedPaths", "publicationHead", "projection", "attempts",
406
+ "ledger", ...(value.version === 1 && !("approved" in value) ? [] : ["approved"]),
407
+ ...("approvalGeneration" in value ? ["approvalGeneration"] : []), "ownedPaths", "publicationHead", "projection", "attempts",
377
408
  ], "sweep recovery state");
378
- if (value.version !== STATE_VERSION || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
409
+ if ((value.version !== 1 && value.version !== STATE_VERSION) || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
379
410
  if (!(["triage", "recorded", "published", "refresh-pending", "refreshed", "resolving", "resolved"] as unknown[]).includes(value.phase)) {
380
411
  throw new Error("sweep recovery phase is invalid");
381
412
  }
@@ -404,6 +435,13 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
404
435
  throw new Error("sweep feedback fingerprints do not match the complete snapshot");
405
436
  }
406
437
  const ledger = value.ledger === null ? null : exactLedger(value.ledger, snapshot);
438
+ const approved = value.version === 1 && !("approved" in value) ? false : value.approved;
439
+ if (typeof approved !== "boolean") throw new Error("sweep approval is invalid");
440
+ const approvalGeneration = value.approvalGeneration === undefined || value.approvalGeneration === null
441
+ ? null : integer(value.approvalGeneration, "sweep approval generation");
442
+ // Old recovery could mark a refreshed ledger approved using only the original plan's approval.
443
+ const freshApproved = ["refreshed", "resolving", "resolved"].includes(value.phase as string)
444
+ ? approved && approvalGeneration === feedback.generation : approved;
407
445
  const ownedPaths = parseOwnedPaths(value.ownedPaths);
408
446
  const publicationHead = value.publicationHead === null ? null : requiredOid(value.publicationHead, "publication head");
409
447
  const projection = value.projection === null ? null : parseProjection(value.projection);
@@ -419,8 +457,8 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
419
457
  if (!Array.isArray(value.attempts.resolutions) || value.attempts.resolutions.length > FEEDBACK_MAX_RECORDS) {
420
458
  throw new Error("resolution attempts are invalid");
421
459
  }
422
- const resolutions = value.attempts.resolutions.map(resolutionAttempt);
423
- if (new Set(resolutions.map(({ generation, threadId }) => `${generation}\0${threadId}`)).size !== resolutions.length) {
460
+ const resolutions = value.attempts.resolutions.map((attempt, index) => resolutionAttempt(attempt, index, value.version as number));
461
+ if (new Set(resolutions.map(({ generation, threadId, step }) => `${generation}\0${threadId}\0${step}`)).size !== resolutions.length) {
424
462
  throw new Error("resolution attempts contain duplicate thread IDs");
425
463
  }
426
464
  exactKeys(value.attempts.finalize, ["state", "checks"], "finalize attempt");
@@ -429,7 +467,7 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
429
467
  checks: parseChecks(value.attempts.finalize.checks),
430
468
  };
431
469
  const state: SweepState = {
432
- version: 1,
470
+ version: value.version as 1 | 2,
433
471
  workflow: "pi-pr-comment-sweep",
434
472
  worktree: { id, root },
435
473
  epoch: integer(value.epoch, "sweep epoch"),
@@ -439,6 +477,8 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
439
477
  original,
440
478
  feedback,
441
479
  ledger,
480
+ approved: freshApproved,
481
+ approvalGeneration,
442
482
  ownedPaths,
443
483
  publicationHead,
444
484
  projection,
@@ -461,7 +501,8 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
461
501
  throw new Error("push attempt head does not match publication head");
462
502
  }
463
503
  if (hasFinalProjection !== (projection !== null)) throw new Error("sweep phase and final projection are inconsistent");
464
- if (projection && (!ledger || !isDeepStrictEqual(projection, buildProjection(feedback.generation, snapshot, ledger)))) {
504
+ if (projection && (!ledger || (!isDeepStrictEqual(projection, buildProjection(feedback.generation, snapshot, ledger)) &&
505
+ !(value.version === 1 && isDeepStrictEqual(projection, buildProjection(feedback.generation, snapshot, ledger, false, true)))))) {
465
506
  throw new Error("final projection does not match feedback and ledger coverage");
466
507
  }
467
508
  return state;
@@ -494,15 +535,49 @@ function status(state: SweepState): SweepStatus {
494
535
  feedbackCount: feedback.length,
495
536
  feedback,
496
537
  ledgerComplete: state.ledger !== null,
538
+ plan: state.ledger ? { ledger: structuredClone(state.ledger), ownedPaths: [...state.ownedPaths] } : null,
539
+ approved: state.approved,
540
+ legacyRecovery: state.version === 1,
497
541
  projection: state.projection ? structuredClone(state.projection) : null,
498
542
  attempts: {
499
543
  push: state.attempts.push.state,
500
- resolutions: state.attempts.resolutions.map(({ threadId, state }) => ({ threadId, state })),
544
+ resolutions: state.attempts.resolutions.map(({ threadId, step, state }) => ({ threadId, step, state })),
501
545
  finalize: state.attempts.finalize.state,
502
546
  },
503
547
  };
504
548
  }
505
549
 
550
+ function addedReply(before: FeedbackSnapshot, after: FeedbackSnapshot, threadId: string, body: string, replyId: string): boolean {
551
+ const previous = before.reviewThreads.find((thread) => thread.id === threadId);
552
+ const current = after.reviewThreads.find((thread) => thread.id === threadId);
553
+ const reply = current?.comments.find(({ id }) => id === replyId);
554
+ return !!previous && !!reply && reply.body === body && !feedbackEntries(before).some(({ id }) => id === replyId);
555
+ }
556
+
557
+ function carryLedger(before: FeedbackSnapshot, after: FeedbackSnapshot, ledger: SweepLedgerEntry[], replyId?: string): SweepLedgerEntry[] {
558
+ const prior = new Map(feedbackEntries(before).map((entry) => [entry.id, entry]));
559
+ const decisions = new Map(ledger.map((entry) => [entry.id, entry]));
560
+ return exactLedger(feedbackEntries(after).map((entry) => {
561
+ const old = prior.get(entry.id);
562
+ const decision = decisions.get(entry.id);
563
+ if (old && decision && old.kind === entry.kind) {
564
+ if (entry.kind !== "thread" && isDeepStrictEqual(old.node, entry.node)) return decision;
565
+ if (entry.kind === "thread") {
566
+ const { comments: _a, isResolved: _b, ...previous } = old.node as FeedbackSnapshot["reviewThreads"][number];
567
+ const { comments: _c, isResolved: _d, ...current } = entry.node as FeedbackSnapshot["reviewThreads"][number];
568
+ if (isDeepStrictEqual(previous, current)) return decision;
569
+ }
570
+ }
571
+ const emptyReview = !old && entry.kind === "review" && "state" in entry.node &&
572
+ entry.node.state !== "CHANGES_REQUESTED" && !entry.node.body;
573
+ return { id: entry.id, kind: entry.kind,
574
+ disposition: entry.id === replyId || emptyReview ? "non-actionable" as const : "blocked" as const,
575
+ note: entry.id === replyId ? "Sweep acknowledgement" : emptyReview ? "Empty review body" :
576
+ "Feedback changed after publication; revisit in the next fix cycle",
577
+ };
578
+ }), after);
579
+ }
580
+
506
581
  function onlyResolutionChanged(before: FeedbackSnapshot, after: FeedbackSnapshot, threadId: string): boolean {
507
582
  if (feedbackContentFingerprint(before) !== feedbackContentFingerprint(after)) return false;
508
583
  const beforeStates = before.reviewThreads.map(({ id, isResolved }) => ({ id, isResolved })).sort((left, right) => left.id.localeCompare(right.id));
@@ -609,11 +684,11 @@ export class PullRequestCommentSweep {
609
684
  await writePrivateTextFileAtomically(location.path, contents, { signal: this.signal });
610
685
  }
611
686
 
612
- private async currentAuthority(expected: SweepAuthority, remoteHead: string): Promise<CurrentPullRequest> {
687
+ private async currentAuthority(expected: SweepAuthority, remoteHead: string, allowBaseDrift = false): Promise<CurrentPullRequest> {
613
688
  const discovery = await this.load(this.pi(), this.context());
614
689
  if (discovery.kind !== "current") throw new Error("Comment sweep cancelled: current pull request authority is unavailable");
615
690
  const current = authorityFromCurrent(discovery.pullRequest);
616
- if (!sameLinkage(expected, current, remoteHead)) throw new Error("Comment sweep cancelled: canonical pull request authority changed");
691
+ if (!sameLinkage(expected, current, remoteHead, allowBaseDrift)) throw new Error("Comment sweep cancelled: canonical pull request authority changed");
617
692
  const remote = await readRemoteOid(this.exec, this.options(), expected.target.fetchSource, expected.target.ref);
618
693
  if (remote !== remoteHead) throw new Error("Comment sweep cancelled: remote lease changed");
619
694
  return discovery.pullRequest;
@@ -668,6 +743,14 @@ export class PullRequestCommentSweep {
668
743
  return snapshot;
669
744
  }
670
745
 
746
+ private applyReply(state: SweepState, after: FeedbackSnapshot, threadId: string, replyId: string): void {
747
+ const parent = state.ledger?.find((entry) => entry.id === threadId);
748
+ if (!parent || !state.ledger || !state.projection) throw new Error("Reply has no recorded thread disposition");
749
+ state.ledger = carryLedger(state.feedback.snapshot, after, state.ledger, replyId);
750
+ this.setFeedback(state, after);
751
+ state.projection = buildProjection(state.feedback.generation, after, state.ledger);
752
+ }
753
+
671
754
  private setFeedback(state: SweepState, snapshot: FeedbackSnapshot, generation = state.feedback.generation): void {
672
755
  state.feedback = {
673
756
  generation,
@@ -694,7 +777,7 @@ export class PullRequestCommentSweep {
694
777
  throw new Error("Comment sweep authority changed during feedback fetch");
695
778
  }
696
779
  const state: SweepState = {
697
- version: 1,
780
+ version: 2,
698
781
  workflow: "pi-pr-comment-sweep",
699
782
  worktree: { id: location.id, root: location.root },
700
783
  epoch: 1,
@@ -709,6 +792,8 @@ export class PullRequestCommentSweep {
709
792
  snapshot,
710
793
  },
711
794
  ledger: null,
795
+ approved: false,
796
+ approvalGeneration: null,
712
797
  ownedPaths: [],
713
798
  publicationHead: null,
714
799
  projection: null,
@@ -744,7 +829,7 @@ export class PullRequestCommentSweep {
744
829
  : state.original.lease;
745
830
  remote = await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref);
746
831
  if (remote !== expectedRemote) throw new Error("Comment sweep remote authority cannot be reconciled");
747
- await this.currentAuthority(state.authority, expectedRemote);
832
+ await this.currentAuthority(state.authority, expectedRemote, state.attempts.push.state === "applied");
748
833
 
749
834
  const pending = state.attempts.resolutions.filter(({ state: attempt }) => attempt === "attempting" || attempt === "unknown");
750
835
  if (pending.length > 1) throw new Error("Multiple unresolved mutation attempts cannot be reconciled");
@@ -755,15 +840,23 @@ export class PullRequestCommentSweep {
755
840
  }
756
841
  const current = await this.collect(state.authority, state.publicationHead);
757
842
  const thread = current.reviewThreads.find(({ id }) => id === attempt.threadId);
758
- if (!thread || feedbackContentFingerprint(current) !== state.feedback.contentFingerprint) {
843
+ if (!thread || (attempt.step === "resolve" && feedbackContentFingerprint(current) !== state.feedback.contentFingerprint)) {
759
844
  throw new Error("Resolution attempt feedback generation changed during recovery");
760
845
  }
761
- if (thread.isResolved && onlyResolutionChanged(state.feedback.snapshot, current, attempt.threadId)) {
846
+ if (attempt.step === "reply" && attempt.replyId &&
847
+ addedReply(state.feedback.snapshot, current, attempt.threadId, attempt.body!, attempt.replyId)) {
848
+ this.applyReply(state, current, attempt.threadId, attempt.replyId);
849
+ attempt.state = "applied";
850
+ attempt.afterFingerprint = state.feedback.fingerprint;
851
+ } else if (feedbackFingerprint(current) === state.feedback.fingerprint) {
852
+ attempt.state = "blocked";
853
+ } else if (attempt.step === "reply") {
854
+ // Without the returned ID an identical comment cannot prove this attempt succeeded.
855
+ throw new Error("Reply attempt outcome is ambiguous; recovery is preserved without replaying the reply");
856
+ } else if (thread.isResolved && onlyResolutionChanged(state.feedback.snapshot, current, attempt.threadId)) {
762
857
  attempt.state = "applied";
763
858
  attempt.afterFingerprint = feedbackFingerprint(current);
764
859
  this.setFeedback(state, current);
765
- } else if (!thread.isResolved && feedbackFingerprint(current) === state.feedback.fingerprint) {
766
- attempt.state = "blocked";
767
860
  } else {
768
861
  throw new Error("Resolution attempt outcome is not exactly reconcilable");
769
862
  }
@@ -780,6 +873,10 @@ export class PullRequestCommentSweep {
780
873
  if (!recoveryMatchesRouteAuthority(state, this.suppliedAuthority)) {
781
874
  throw new Error(`Comment sweep recovery is preserved at ${location.path}: recovery does not match supplied route authority`);
782
875
  }
876
+ if (state.version === 1 && state.projection && state.ledger) {
877
+ // Version-one projections could resolve a parent despite a blocked child.
878
+ state.projection = buildProjection(state.feedback.generation, state.feedback.snapshot, state.ledger);
879
+ }
783
880
  await this.reconcile(state);
784
881
  state.attempts.resolutions = state.attempts.resolutions.filter(({ state: attempt }) => attempt !== "blocked");
785
882
  if (state.attempts.push.state === "blocked") {
@@ -788,9 +885,14 @@ export class PullRequestCommentSweep {
788
885
  }
789
886
  if (state.attempts.finalize.state === "blocked") state.attempts.finalize = { state: "none", checks: [] };
790
887
  const published = state.attempts.push.state === "applied";
888
+ if (published) state.version = 2;
791
889
  const expectedRemote = published ? state.publicationHead! : state.original.lease;
792
- await this.currentAuthority(state.authority, expectedRemote);
890
+ await this.currentAuthority(state.authority, expectedRemote, published);
793
891
  await this.requireOwnedLocalState(state, published ? expectedRemote : undefined);
892
+ if (state.ledger) {
893
+ state.approved = true;
894
+ state.approvalGeneration = state.feedback.generation;
895
+ }
794
896
  state.epoch += 1;
795
897
  state.runId = safeRunId(this.newRunId());
796
898
  await this.save(location, state);
@@ -814,12 +916,20 @@ export class PullRequestCommentSweep {
814
916
  requireGuard(state, guard);
815
917
  if (state.phase === "triage" && state.ledger === null) {
816
918
  if (ownedPathsInput === undefined) throw new Error("Initial comment sweep ledger requires ownedPaths");
919
+ await this.requireCleanPublication(state, state.original.head);
920
+ await this.currentAuthority(state.authority, state.original.lease);
817
921
  state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
922
+ state.approved = true;
923
+ state.approvalGeneration = state.feedback.generation;
818
924
  state.ownedPaths = parseOwnedPaths(ownedPathsInput);
819
925
  state.phase = "recorded";
820
926
  } else if (state.phase === "refresh-pending" && state.ledger === null) {
821
927
  if (ownedPathsInput !== undefined) throw new Error("Refreshed comment sweep ledger cannot change ownedPaths");
928
+ await this.requireCleanPublication(state, state.publicationHead!);
929
+ await this.currentAuthority(state.authority, state.publicationHead!);
822
930
  state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
931
+ state.approved = true;
932
+ state.approvalGeneration = state.feedback.generation;
823
933
  state.projection = buildProjection(state.feedback.generation, state.feedback.snapshot, state.ledger);
824
934
  state.phase = "refreshed";
825
935
  } else {
@@ -835,7 +945,7 @@ export class PullRequestCommentSweep {
835
945
  const location = await this.location();
836
946
  const state = await this.loadState(location);
837
947
  requireGuard(state, guard);
838
- if (state.phase !== "recorded" || !state.ledger || state.attempts.push.state !== "none") {
948
+ if (state.phase !== "recorded" || !state.ledger || state.attempts.push.state !== "none" || !state.approved) {
839
949
  throw new Error("Comment sweep is not ready to publish");
840
950
  }
841
951
  const head = await this.requireOwnedLocalState(state);
@@ -845,6 +955,7 @@ export class PullRequestCommentSweep {
845
955
  state.publicationHead = head;
846
956
  state.attempts.push = { state: "applied", head };
847
957
  state.phase = "published";
958
+ state.version = 2;
848
959
  await this.save(location, state);
849
960
  return status(state);
850
961
  }
@@ -870,6 +981,7 @@ export class PullRequestCommentSweep {
870
981
  await this.currentAuthority(state.authority, head);
871
982
  state.attempts.push.state = "applied";
872
983
  state.phase = "published";
984
+ state.version = 2;
873
985
  await this.save(location, state);
874
986
  return status(state);
875
987
  } catch (error) {
@@ -895,45 +1007,90 @@ export class PullRequestCommentSweep {
895
1007
  throw new Error("Comment sweep has an unreconciled finalization attempt; use resume");
896
1008
  }
897
1009
  await this.requireCleanPublication(state, state.publicationHead);
898
- await this.currentAuthority(state.authority, state.publicationHead);
899
- const snapshot = await this.collect(state.authority, state.publicationHead);
900
- await this.currentAuthority(state.authority, state.publicationHead);
1010
+ const current = await this.currentAuthority(state.authority, state.publicationHead, true);
1011
+ const authority = { ...state.authority, base: { ...state.authority.base, oid: current.base.oid } };
1012
+ const snapshot = await this.collect(authority, state.publicationHead);
1013
+ await this.currentAuthority(authority, state.publicationHead);
901
1014
  await this.requireCleanPublication(state, state.publicationHead);
1015
+ const ledger = state.ledger ? carryLedger(state.feedback.snapshot, snapshot, state.ledger) : null;
1016
+ state.authority = authority;
902
1017
  this.setFeedback(state, snapshot, state.feedback.generation + 1);
903
- state.ledger = null;
904
- state.projection = null;
905
- state.attempts.resolutions = [];
1018
+ state.ledger = ledger;
1019
+ state.approved = ledger !== null;
1020
+ state.approvalGeneration = ledger ? state.feedback.generation : null;
1021
+ state.projection = ledger ? buildProjection(state.feedback.generation, snapshot, ledger) : null;
906
1022
  state.attempts.finalize = { state: "none", checks: [] };
907
- state.phase = "refresh-pending";
1023
+ state.phase = ledger ? "refreshed" : "refresh-pending";
908
1024
  await this.save(location, state);
909
1025
  return status(state);
910
1026
  }, { agentDir: this.agentDir, signal: this.signal });
911
1027
  }
912
1028
 
913
- async resolve(guard: SweepRunGuard, threadIdsInput: string[]): Promise<SweepStatus> {
1029
+ async resolve(guard: SweepRunGuard, threadIdsInput?: string[]): Promise<SweepStatus> {
914
1030
  return await withWorktreeLock(this.cwd, async () => {
915
1031
  const location = await this.location();
916
1032
  const state = await this.loadState(location);
917
1033
  requireGuard(state, guard);
918
- if (!["refreshed", "resolving"].includes(state.phase) || !state.publicationHead || !state.ledger || !state.projection) {
1034
+ if (!["refreshed", "resolving", "resolved"].includes(state.phase) || !state.publicationHead || !state.ledger || !state.projection || !state.approved) {
919
1035
  throw new Error("Comment sweep is not ready to resolve threads");
920
1036
  }
921
1037
  if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
922
1038
  throw new Error("Comment sweep has an unreconciled thread mutation; use resume");
923
1039
  }
924
- if (!Array.isArray(threadIdsInput) || threadIdsInput.length > FEEDBACK_MAX_RECORDS) throw new Error("threadIds must be a bounded array");
925
- const threadIds = threadIdsInput.map((id, index) => requiredText(id, `thread ID ${index + 1}`));
926
- if (new Set(threadIds).size !== threadIds.length) throw new Error("threadIds contain duplicates");
1040
+ if (threadIdsInput !== undefined && (!Array.isArray(threadIdsInput) || threadIdsInput.length > FEEDBACK_MAX_RECORDS)) {
1041
+ throw new Error("threadIds must be a bounded array");
1042
+ }
927
1043
  const ledger = new Map(state.ledger.map((entry) => [entry.id, entry]));
1044
+ const threadIds = (threadIdsInput ?? state.feedback.snapshot.reviewThreads.filter((thread) =>
1045
+ !thread.isResolved && ["addressed", "non-actionable"].includes(ledger.get(thread.id)?.disposition ?? "blocked") &&
1046
+ !thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")
1047
+ ).map(({ id }) => id)).map((id, index) => requiredText(id, `thread ID ${index + 1}`));
1048
+ if (new Set(threadIds).size !== threadIds.length) throw new Error("threadIds contain duplicates");
928
1049
  for (const threadId of threadIds) {
929
1050
  const thread = state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId);
930
- if (!thread || thread.isResolved || ledger.get(threadId)?.kind !== "thread" || ledger.get(threadId)?.disposition !== "addressed") {
931
- throw new Error(`Only addressed unresolved review thread IDs may be resolved: ${threadId}`);
1051
+ const disposition = ledger.get(threadId)?.disposition;
1052
+ if (!thread || thread.isResolved || ledger.get(threadId)?.kind !== "thread" ||
1053
+ (disposition !== "addressed" && disposition !== "non-actionable")) {
1054
+ throw new Error(`Only addressed or non-actionable unresolved review thread IDs may be resolved: ${threadId}`);
1055
+ }
1056
+ if (state.attempts.resolutions.some((attempt) => attempt.generation === state.feedback.generation && attempt.threadId === threadId && attempt.step === "resolve")) {
1057
+ throw new Error(`Review thread resolution was already attempted: ${threadId}`);
1058
+ }
1059
+ if (disposition === "non-actionable" && !ledger.get(threadId)!.note.trim()) {
1060
+ throw new Error(`Non-actionable review thread needs a reason: ${threadId}`);
932
1061
  }
933
- if (state.attempts.resolutions.some((attempt) => attempt.generation === state.feedback.generation && attempt.threadId === threadId)) {
934
- throw new Error(`Review thread mutation was already attempted: ${threadId}`);
1062
+ if (thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")) {
1063
+ throw new Error(`Review thread has blocked child feedback: ${threadId}`);
935
1064
  }
936
1065
  }
1066
+ const hasReply = (threadId: string) => state.attempts.resolutions.some((attempt) =>
1067
+ attempt.threadId === threadId && attempt.step === "reply" && attempt.state === "applied" &&
1068
+ (attempt.generation === state.feedback.generation || !!state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId)
1069
+ ?.comments.some(({ id }) => id === attempt.replyId)));
1070
+ for (const threadId of threadIds) {
1071
+ const oldReplies = state.attempts.resolutions.filter((attempt) =>
1072
+ attempt.threadId === threadId && attempt.step === "reply" && attempt.state === "applied");
1073
+ if (oldReplies.some((attempt) => !attempt.replyId && attempt.generation !== state.feedback.generation)) {
1074
+ throw new Error(`Prior reply has no verified ID after refresh: ${threadId}`);
1075
+ }
1076
+ if (oldReplies.some((attempt) => attempt.replyId && !state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId)
1077
+ ?.comments.some(({ id }) => id === attempt.replyId))) {
1078
+ throw new Error(`Verified reply is missing from fresh feedback: ${threadId}`);
1079
+ }
1080
+ }
1081
+ const replyBodies = new Map(threadIds.filter((threadId) => !hasReply(threadId)).map((threadId) => {
1082
+ const entry = ledger.get(threadId)!;
1083
+ return [threadId, entry.disposition === "addressed"
1084
+ ? `${new URL(state.authority.url).origin}/${state.authority.base.repository}/commit/${state.publicationHead}`
1085
+ : entry.note.trim()] as const;
1086
+ }));
1087
+ const reserveBytes = [...replyBodies.values()].reduce((total, body) =>
1088
+ total + REPLY_METADATA_RESERVE_BYTES + 2 * Buffer.byteLength(body, "utf8"), 0);
1089
+ if (feedbackEntries(state.feedback.snapshot).length + replyBodies.size > FEEDBACK_MAX_RECORDS ||
1090
+ Buffer.byteLength(JSON.stringify(state.feedback.snapshot), "utf8") + reserveBytes > FEEDBACK_SNAPSHOT_MAX_BYTES ||
1091
+ Buffer.byteLength(JSON.stringify(state), "utf8") + 2 * reserveBytes > SWEEP_RECOVERY_MAX_BYTES) {
1092
+ throw new Error("Comment sweep has insufficient feedback capacity for review thread replies");
1093
+ }
937
1094
  for (const threadId of threadIds) {
938
1095
  await this.requireCleanPublication(state, state.publicationHead);
939
1096
  await this.currentAuthority(state.authority, state.publicationHead);
@@ -945,12 +1102,48 @@ export class PullRequestCommentSweep {
945
1102
  await this.requireCleanPublication(state, state.publicationHead);
946
1103
  const thread = before.reviewThreads.find(({ id }) => id === threadId);
947
1104
  if (!thread || thread.isResolved) throw new Error(`Review thread is no longer unresolved: ${threadId}`);
1105
+ if (thread.comments.some((comment) => state.ledger!.find(({ id }) => id === comment.id)?.disposition === "blocked")) continue;
1106
+ const replied = hasReply(threadId);
1107
+ if (!replied) {
1108
+ const body = replyBodies.get(threadId)!;
1109
+ const replyAttempt: ResolutionAttempt = {
1110
+ generation: state.feedback.generation, threadId, step: "reply", body, replyId: null,
1111
+ state: "attempting", beforeFingerprint: state.feedback.fingerprint, afterFingerprint: null,
1112
+ };
1113
+ state.attempts.resolutions.push(replyAttempt);
1114
+ state.phase = "resolving";
1115
+ await this.save(location, state);
1116
+ let afterReply: FeedbackSnapshot;
1117
+ let replyId: string;
1118
+ try {
1119
+ replyId = await replyToPullRequestThread(state.feedback.snapshot.pullRequest, threadId, body, {
1120
+ exec: this.exec, cwd: this.cwd, signal: this.signal, pause: this.pause,
1121
+ });
1122
+ replyAttempt.replyId = replyId;
1123
+ await this.save(location, state);
1124
+ afterReply = await this.collect(state.authority, state.publicationHead);
1125
+ await this.currentAuthority(state.authority, state.publicationHead);
1126
+ await this.requireCleanPublication(state, state.publicationHead);
1127
+ if (!addedReply(before, afterReply, threadId, body, replyId)) {
1128
+ throw new Error(`Thread reply did not produce the verified reply ID: ${threadId}`);
1129
+ }
1130
+ } catch (error) {
1131
+ replyAttempt.state = "unknown";
1132
+ await this.save(location, state);
1133
+ throw error;
1134
+ }
1135
+ this.applyReply(state, afterReply, threadId, replyId);
1136
+ replyAttempt.state = "applied";
1137
+ replyAttempt.afterFingerprint = state.feedback.fingerprint;
1138
+ await this.save(location, state);
1139
+ }
1140
+ const currentThread = state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId)!;
1141
+ if (currentThread.isResolved || currentThread.comments.some((comment) =>
1142
+ state.ledger!.find(({ id }) => id === comment.id)?.disposition === "blocked")) continue;
1143
+ const beforeResolution = state.feedback.snapshot;
948
1144
  const attempt: ResolutionAttempt = {
949
- generation: state.feedback.generation,
950
- threadId,
951
- state: "attempting",
952
- beforeFingerprint: state.feedback.fingerprint,
953
- afterFingerprint: null,
1145
+ generation: state.feedback.generation, threadId, step: "resolve", body: null, replyId: null,
1146
+ state: "attempting", beforeFingerprint: state.feedback.fingerprint, afterFingerprint: null,
954
1147
  };
955
1148
  state.attempts.resolutions.push(attempt);
956
1149
  state.phase = "resolving";
@@ -963,7 +1156,7 @@ export class PullRequestCommentSweep {
963
1156
  after = await this.collect(state.authority, state.publicationHead);
964
1157
  await this.currentAuthority(state.authority, state.publicationHead);
965
1158
  await this.requireCleanPublication(state, state.publicationHead);
966
- if (!onlyResolutionChanged(before, after, threadId)) {
1159
+ if (!onlyResolutionChanged(beforeResolution, after, threadId)) {
967
1160
  throw new Error(`Thread resolution did not produce the exact verified transition: ${threadId}`);
968
1161
  }
969
1162
  } catch (error) {
@@ -996,18 +1189,15 @@ export class PullRequestCommentSweep {
996
1189
 
997
1190
  async finalize(
998
1191
  guard: SweepRunGuard,
999
- projectionInput: SweepFinalProjection,
1000
1192
  checksInput: SweepCheck[],
1001
1193
  ): Promise<{ kind: "finalized"; pullRequestUrl: string; head: string; checks: number }> {
1002
1194
  return await withWorktreeLock(this.cwd, async () => {
1003
1195
  const location = await this.location();
1004
1196
  const state = await this.loadState(location);
1005
1197
  requireGuard(state, guard);
1006
- if (!["refreshed", "resolving", "resolved"].includes(state.phase) || !state.projection || !state.publicationHead) {
1198
+ if (!["refreshed", "resolving", "resolved"].includes(state.phase) || !state.projection || !state.publicationHead || !state.approved) {
1007
1199
  throw new Error("Comment sweep is not ready to finalize");
1008
1200
  }
1009
- const projection = parseProjection(projectionInput);
1010
- if (!isDeepStrictEqual(projection, state.projection)) throw new Error("Final projection does not match the declared refreshed projection");
1011
1201
  const checks = parseChecks(checksInput);
1012
1202
  if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
1013
1203
  throw new Error("Comment sweep has unresolved or unknown mutation attempts");
@@ -1047,6 +1237,8 @@ export class PullRequestCommentSweep {
1047
1237
  }
1048
1238
  await this.freshProjection(state);
1049
1239
  await this.save(location, state);
1240
+ await markFeedbackHandled(state.feedback.snapshot, { cwd: this.cwd, agentDir: this.agentDir, signal: this.signal, exec: this.exec,
1241
+ blockedIds: state.ledger!.filter(({ disposition }) => disposition === "blocked").map(({ id }) => id) });
1050
1242
  await rm(location.path);
1051
1243
  return { kind: "finalized", pullRequestUrl: state.authority.url, head: state.publicationHead, checks: checks.length };
1052
1244
  }, { agentDir: this.agentDir, signal: this.signal });