prreviewbuddy 0.25.8 → 0.25.13

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.
@@ -2002,15 +2002,11 @@ async function defaultRemoteBranch(repoPath) {
2002
2002
  async function buildChangeSet(options) {
2003
2003
  const repoPath = (await git(options.repoPath, ["rev-parse", "--show-toplevel"])).trim();
2004
2004
  const base = options.resolvedBase ?? await resolveBase(repoPath, options.base);
2005
- const mergeBase = (await git(repoPath, [
2006
- "merge-base",
2007
- base.revision,
2008
- "HEAD"
2009
- ])).trim();
2005
+ const mergeBase = await mergeBaseOf(repoPath, base, "HEAD");
2010
2006
  const headRef = await currentBranch(repoPath);
2011
2007
  const isLinkedWorktree = await linkedWorktree(repoPath);
2012
2008
  const files = await collectFiles(repoPath, [mergeBase, "HEAD"]);
2013
- if (files.length === 0) throw emptyChange(headRef, base.display, options.requestNumber);
2009
+ if (files.length === 0) throw emptyChange(headRef, base.display, options.requestNumber === void 0 ? {} : { requestNumber: options.requestNumber });
2014
2010
  disagreement(files.length, base.display, options.expected);
2015
2011
  const blobShas = await headBlobShas(repoPath);
2016
2012
  for (const file of files) file.sha = blobShas.get(file.name) ?? "";
@@ -2073,10 +2069,19 @@ function disagreement(found, baseRef, expected) {
2073
2069
  *
2074
2070
  * The message names both refs, because "no changes" on its own reads as a bug when the branch is
2075
2071
  * visibly full of work and the real story is that it was compared against itself.
2072
+ *
2073
+ * `uncommitted` is only ever counted by a caller standing in the reviewer's own checkout, which is
2074
+ * `resolveTarget`. This module usually runs inside the isolated worktree, where `git status` is
2075
+ * clean by construction, so it could never see the edits it would be explaining.
2076
2076
  */
2077
- function emptyChange(headRef, baseRef, requestNumber) {
2077
+ function emptyChange(headRef, baseRef, { requestNumber, uncommitted = 0 } = {}) {
2078
2078
  if (requestNumber !== void 0) return new EmptyChangeError(`Pull request ${requestNumber} is already merged into ${baseRef}, so there is nothing left to compare. PR Review Buddy reviews a change before it lands, and a merged request has no diff against the branch it went into. Review the branch it was merged from, if it is still around, or a request that is still open.`);
2079
- return new EmptyChangeError(`No changes found to review. ${headRef === baseRef ? `${headRef} is the base it would be compared against` : `${headRef} has no commits that ${baseRef} does not`}. Switch to the branch containing your changes, or commit them first.`);
2079
+ const comparison = headRef === baseRef ? `${headRef} is the base it would be compared against` : `${headRef} has no commits that ${baseRef} does not`;
2080
+ if (uncommitted === 0) return new EmptyChangeError(`No committed changes to review. ${comparison}. Switch to the branch containing your changes, or commit them first.`);
2081
+ const remedy = headRef === baseRef ? "Commit them on a branch, then try again." : "Commit them, then try again.";
2082
+ return new EmptyChangeError(`No committed changes to review. ${comparison}.\n\nYou have ${uncommitted} uncommitted ${uncommitted === 1 ? "change" : "changes"}. PR Review Buddy reviews a fixed snapshot of your code, so uncommitted changes are not included.
2083
+
2084
+ ` + remedy);
2080
2085
  }
2081
2086
  /**
2082
2087
  * A linked worktree has its own `.git` file pointing into the main repository's directory, so the
@@ -2189,12 +2194,44 @@ async function headBlobShas(repoPath) {
2189
2194
  }
2190
2195
  return map;
2191
2196
  }
2192
- async function collectFiles(repoPath, range) {
2193
- const diffArgs = [
2197
+ /**
2198
+ * The comparison every question about "what changed" is asked with.
2199
+ *
2200
+ * One definition, because `resolveTarget` asks whether a change is empty before any worktree
2201
+ * exists and `buildChangeSet` asks again inside one. Two spellings of the range would let the two
2202
+ * answers drift, and the early refusal would then turn away a change the review could have read.
2203
+ */
2204
+ function rangeDiff(range) {
2205
+ return [
2194
2206
  "diff",
2195
2207
  "--find-renames",
2196
2208
  ...range
2197
2209
  ];
2210
+ }
2211
+ async function mergeBaseOf(repoPath, base, head) {
2212
+ return (await git(repoPath, [
2213
+ "merge-base",
2214
+ base.revision,
2215
+ head
2216
+ ])).trim();
2217
+ }
2218
+ /**
2219
+ * Whether `head` carries any committed change against `base`, by the same comparison
2220
+ * `buildChangeSet` makes.
2221
+ *
2222
+ * Asks for the file list rather than comparing the merge base with `head`, because those disagree:
2223
+ * a branch whose commits cancel out (a change and its revert) is ahead of its base and still has
2224
+ * nothing to review, and the change set would refuse it.
2225
+ */
2226
+ async function hasCommittedChanges(repoPath, base, head) {
2227
+ return parseNameStatus(await git(repoPath, [
2228
+ ...rangeDiff([await mergeBaseOf(repoPath, base, head), head]),
2229
+ "--name-status",
2230
+ "-z"
2231
+ ])).length > 0;
2232
+ }
2233
+ async function collectFiles(repoPath, range) {
2234
+ const diffArgs = rangeDiff(range);
2198
2235
  const [nameStatus, numstat, combinedPatch] = await Promise.all([
2199
2236
  git(repoPath, [
2200
2237
  ...diffArgs,
@@ -2581,6 +2618,53 @@ async function verifyDefaultBase(root) {
2581
2618
  }
2582
2619
  }
2583
2620
  /**
2621
+ * Refuse a review of nothing before one is created, rather than from inside it.
2622
+ *
2623
+ * `buildChangeSet` refuses an empty comparison too, but it runs in the job's `context` phase: after
2624
+ * the workspace is minted and a full `git worktree add`, so the refusal was reported as a *paused*
2625
+ * review whose "run it again and it carries on" refuses identically for ever. Called from `startJob`
2626
+ * rather than `resolveTarget`, because a resolved target may still be handed an existing review, and
2627
+ * a branch that has since been merged must keep opening the review it already has.
2628
+ *
2629
+ * Asks the question `buildChangeSet` asks, through `hasCommittedChanges`, against the base the job
2630
+ * will use (`baseFor` in `review_job.ts`), so the two cannot disagree about what "empty" means. A
2631
+ * base that does not resolve is left for the job, which already says so in its own words.
2632
+ */
2633
+ async function refuseEmptyTarget(target) {
2634
+ const root = target.originRepoPath;
2635
+ let changed;
2636
+ let baseDisplay;
2637
+ try {
2638
+ const base = target.resolvedBase ?? await resolveBase(root, target.base);
2639
+ baseDisplay = base.display;
2640
+ changed = await hasCommittedChanges(root, base, target.sha);
2641
+ } catch {
2642
+ return;
2643
+ }
2644
+ if (changed) return;
2645
+ throw emptyChange(target.branch, baseDisplay, {
2646
+ ...target.pullRequest ? { requestNumber: target.pullRequest.number } : {},
2647
+ uncommitted: await uncommittedIn(root, target)
2648
+ });
2649
+ }
2650
+ /**
2651
+ * How many changes `git status` would show, but only when this checkout is the thing under review.
2652
+ *
2653
+ * Your edits explain an empty review only when they sit on the commit being reviewed. Standing on
2654
+ * `main` while naming a level `feat/x`, they are somebody else's business, and `--branch` promises
2655
+ * never to read the checkout at all. Untracked files count, because they are visible work too, and
2656
+ * the count is by line of `--porcelain` rather than `-z`, where a rename takes two records.
2657
+ */
2658
+ async function uncommittedIn(root, target) {
2659
+ if (target.pullRequest) return 0;
2660
+ try {
2661
+ if ((await git(root, ["rev-parse", "HEAD"])).trim() !== target.sha || await branchOf(root) !== target.branch) return 0;
2662
+ return (await git(root, ["status", "--porcelain"])).split("\n").filter((line) => line.trim()).length;
2663
+ } catch {
2664
+ return 0;
2665
+ }
2666
+ }
2667
+ /**
2584
2668
  * The base half of a target, rebuilt from a review that already recorded one.
2585
2669
  *
2586
2670
  * Three places rebuild a target from a stored review: re-analysing one, resuming a job, and pinning
@@ -3485,6 +3569,81 @@ function saveWorkspace(workspace) {
3485
3569
  if (!existsSync(workspacePath(workspace.id))) return;
3486
3570
  writeWorkspace(workspace);
3487
3571
  }
3572
+ /**
3573
+ * An opaque token representing the observed version of this review's mutable state.
3574
+ *
3575
+ * **Tokens support equality comparison only.** The implementation derives the token from
3576
+ * filesystem metadata; no caller may depend on that, and none may order two tokens. mtime is not a
3577
+ * monotonic application revision — a clock change, a file restored from a backup and a filesystem
3578
+ * with coarse timestamps all break ordering, and none of them break equality. Keeping the contract
3579
+ * this narrow is what lets a counter, a content hash or a composite over several files replace the
3580
+ * body of this function later without touching the wire or the page.
3581
+ *
3582
+ * So a consumer asking "did the workspace change under this page" writes:
3583
+ *
3584
+ * if (pageRevision != null && serverRevision != null && pageRevision !== serverRevision) {
3585
+ * // state changed
3586
+ * }
3587
+ *
3588
+ * Never the bare `!==`, and never `>`. **An absent token means unknown, not equal**: two reviews
3589
+ * that both failed to produce one have told you nothing, and a bare `!==` over two `undefined`s
3590
+ * silently concludes "unchanged", which is the opposite of what happened.
3591
+ *
3592
+ * Opaque includes its shape. `mtimeMs` carries sub-millisecond precision, so the token is a float
3593
+ * and reaches the page as one; a consumer that `parseInt`s it would throw away the digits that
3594
+ * distinguish two writes in the same millisecond. Compare what you were given, unchanged.
3595
+ *
3596
+ * Nothing reads this yet, deliberately. It is laid down so that a later auto-refresh can ask a
3597
+ * cheap question without a protocol having to be invented at the same time as the feature, and
3598
+ * building the polling now would be committing to an answer before there is a question.
3599
+ *
3600
+ * `ENOENT` only. A review whose file has gone is the one condition "unknown" honestly describes,
3601
+ * and it is a real race: a route loads the workspace and then stats it, so a deletion in between
3602
+ * lands here. Any other stat failure is a fault on this machine and is left to surface, because a
3603
+ * permissions problem dressed as an absent token is a product where nothing is wrong and nothing
3604
+ * works.
3605
+ */
3606
+ function workspaceRevision(workspace) {
3607
+ try {
3608
+ return statSync(workspacePath(workspace.id)).mtimeMs;
3609
+ } catch (error) {
3610
+ if (error?.code === "ENOENT") return void 0;
3611
+ throw error;
3612
+ }
3613
+ }
3614
+ /**
3615
+ * Every stored review as an id and a revision token, without reading one of them.
3616
+ *
3617
+ * Here rather than in `reviews_revision.ts` because this module owns the layout of `workspaces/`,
3618
+ * the same reason `deleteWorkspace` is here. What the caller gets is deliberately not a review: it
3619
+ * is the pair that answers "has anything the list draws changed", for every review at once.
3620
+ *
3621
+ * **`stat` only, never a parse.** A review record holds the whole diff and can be megabytes, and
3622
+ * this is read on a one-second poll while an analysis runs. Reading them properly through
3623
+ * `recentWorkspaces` would be parsing every diff on the machine several times a minute to find out
3624
+ * whether anything had changed, which is a worse bargain than the staleness it fixes.
3625
+ *
3626
+ * **Sorted by id.** `readdirSync` promises no order, so an unsorted answer would produce a
3627
+ * different token for an unchanged directory whenever the filesystem felt like enumerating
3628
+ * differently. On a design that reloads the page when the token changes, that is a reload the
3629
+ * reviewer cannot account for.
3630
+ */
3631
+ function workspaceStamps() {
3632
+ let names;
3633
+ try {
3634
+ names = readdirSync(WORKSPACES_DIR);
3635
+ } catch {
3636
+ return [];
3637
+ }
3638
+ const stamps = [];
3639
+ for (const name of names) {
3640
+ if (!name.endsWith(".json")) continue;
3641
+ try {
3642
+ stamps.push([name.slice(0, -5), statSync(join(WORKSPACES_DIR, name)).mtimeMs]);
3643
+ } catch {}
3644
+ }
3645
+ return stamps.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
3646
+ }
3488
3647
  function writeWorkspace(workspace) {
3489
3648
  mkdirSync(WORKSPACES_DIR, { recursive: true });
3490
3649
  writeFileSync(workspacePath(workspace.id), JSON.stringify(workspace), { mode: 384 });
@@ -4102,6 +4261,10 @@ var WORK_PHASES = [
4102
4261
  "conversation",
4103
4262
  "analysing"
4104
4263
  ];
4264
+ /** A record written before jobs said what they were for was always a review. */
4265
+ function purposeOf(job) {
4266
+ return job.purpose ?? "review";
4267
+ }
4105
4268
  /**
4106
4269
  * Two forms per phase, because a finished step and a running one are different sentences.
4107
4270
  *
@@ -4136,6 +4299,49 @@ var PHASES = {
4136
4299
  }
4137
4300
  };
4138
4301
  /**
4302
+ * The same phases, as an update runs them.
4303
+ *
4304
+ * An update reuses the phase enum because the sweep, the claims and `isTerminal` all key on it, but
4305
+ * the work behind each phase is different and so is the order: the conversation is re-read before
4306
+ * any checkout is made, because a review with no new commits stops there and needs no checkout.
4307
+ */
4308
+ var UPDATE_PHASES = {
4309
+ preparing: {
4310
+ doing: "Fetching the latest commits",
4311
+ done: "Fetched the latest commits"
4312
+ },
4313
+ conversation: {
4314
+ doing: "Re-reading pull request conversation",
4315
+ done: "Re-read pull request conversation"
4316
+ },
4317
+ context: {
4318
+ doing: "Preparing a checkout of the new commits",
4319
+ done: "Prepared a checkout of the new commits"
4320
+ },
4321
+ analysing: {
4322
+ doing: "Reassessing the review against the new commits",
4323
+ done: "Reassessed the review against the new commits"
4324
+ },
4325
+ done: {
4326
+ doing: "Finishing",
4327
+ done: "Finished"
4328
+ },
4329
+ failed: {
4330
+ doing: "Stopped",
4331
+ done: "Stopped"
4332
+ }
4333
+ };
4334
+ var UPDATE_ORDER = [
4335
+ "preparing",
4336
+ "conversation",
4337
+ "context",
4338
+ "analysing"
4339
+ ];
4340
+ /** The names this job's phases go by. */
4341
+ function phasesFor(job) {
4342
+ return purposeOf(job) === "update" ? UPDATE_PHASES : PHASES;
4343
+ }
4344
+ /**
4139
4345
  * Whether a phase is part of *this* review, rather than part of the pipeline in general.
4140
4346
  *
4141
4347
  * Only `conversation` is ever optional, and reviewing a branch with no pull request is an ordinary
@@ -4171,12 +4377,15 @@ function appliesTo(job, phase) {
4171
4377
  */
4172
4378
  function progressSteps(job) {
4173
4379
  const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
4174
- return WORK_PHASES.filter((phase) => appliesTo(job, phase)).map((phase) => {
4175
- const complete = job.completed.includes(phase) || job.phase === "done";
4380
+ const update = purposeOf(job) === "update";
4381
+ const names = phasesFor(job);
4382
+ const order = (update ? UPDATE_ORDER : WORK_PHASES).filter((phase) => appliesTo(job, phase));
4383
+ return (update && job.phase === "done" ? order.filter((phase) => job.completed.includes(phase)) : order).map((phase) => {
4384
+ const complete = job.completed.includes(phase) || !update && job.phase === "done";
4176
4385
  const failed = failedAt === phase;
4177
4386
  const active = !complete && !failed && job.phase === phase;
4178
4387
  const state = failed ? "failed" : complete ? "complete" : active ? "active" : "pending";
4179
- const label = complete ? PHASES[phase].done : PHASES[phase].doing;
4388
+ const label = complete ? names[phase].done : names[phase].doing;
4180
4389
  const note = active && job.progress && !restates(job.progress, label) ? job.progress : void 0;
4181
4390
  return {
4182
4391
  phase,
@@ -4342,6 +4551,20 @@ function recordFailure(job, message, driver, kind, detail) {
4342
4551
  saveJob(failed);
4343
4552
  return failed;
4344
4553
  }
4554
+ /**
4555
+ * When this job stopped doing work.
4556
+ *
4557
+ * `finishedAt` is written on both terminal transitions, and a record older than the field falls
4558
+ * back to the last time anything touched it -- which, for a job that has ended, is the write that
4559
+ * ended it. Lifted out of `sweep.ts`, where it was private, once a second caller needed the same
4560
+ * question: two runs on one review can overlap, so "which of these ended last" is not answered by
4561
+ * which of them started last.
4562
+ *
4563
+ * Meaningless for a job still running, and no caller asks it of one.
4564
+ */
4565
+ function endedAt(job) {
4566
+ return job.finishedAt ?? job.updatedAt;
4567
+ }
4345
4568
  function isTerminal(job) {
4346
4569
  return job.phase === "done" || job.phase === "failed";
4347
4570
  }
@@ -8439,6 +8662,7 @@ async function startJob(input) {
8439
8662
  base: input.base,
8440
8663
  resolveRequest: input.resolveRequest
8441
8664
  }), input.base);
8665
+ await refuseEmptyTarget(target);
8442
8666
  const agent = input.agentId ? agentById(input.agentId) : defaultAgent();
8443
8667
  const review = input.review ?? { type: "standard" };
8444
8668
  const workspace = createWorkspace({
@@ -8454,6 +8678,7 @@ async function startJob(input) {
8454
8678
  workspaceId: workspace.id,
8455
8679
  target,
8456
8680
  env: agent.captureEnv(),
8681
+ purpose: "review",
8457
8682
  ...review.type === "kiss" ? {
8458
8683
  reviewType: "kiss",
8459
8684
  audience: review.audience
@@ -8485,13 +8710,13 @@ async function startJob(input) {
8485
8710
  * The previous result is snapshotted before the session is replaced, so the review keeps saying who
8486
8711
  * made the analysis it is retiring as well as who made the one it now shows.
8487
8712
  */
8488
- async function reanalyseReview(workspaceId, agentId) {
8713
+ async function reanalyseReview(workspaceId, agentId, onStarted) {
8489
8714
  const workspace = loadWorkspace(workspaceId);
8490
8715
  if (!workspace) throw new Error("No such review.");
8491
8716
  const sha = workspace.session.metadata?.commitSha;
8492
8717
  if (!sha) throw new Error("This review does not record which commit it describes, so it cannot be analysed again.");
8493
8718
  const agent = agentId ? agentById(agentId) : agentById(agentIdOf(workspace.session));
8494
- if (isBusy(await withReviewLock(workspaceId, () => {
8719
+ const started = await withReviewLock(workspaceId, () => {
8495
8720
  if (workspace.session.result) workspace.previousResult = {
8496
8721
  result: workspace.session.result,
8497
8722
  commitSha: sha,
@@ -8508,13 +8733,17 @@ async function reanalyseReview(workspaceId, agentId) {
8508
8733
  const job = createJob({
8509
8734
  workspaceId: workspace.id,
8510
8735
  target,
8511
- env: agent.captureEnv()
8736
+ env: agent.captureEnv(),
8737
+ purpose: "review"
8512
8738
  });
8513
8739
  saveJob(job);
8514
8740
  workspace.jobId = job.id;
8515
8741
  workspace.session = beginReviewSession(sha, agent);
8516
8742
  saveWorkspace(workspace);
8517
- }))) throw new ReviewBeingDeletedError();
8743
+ return job;
8744
+ });
8745
+ if (isBusy(started)) throw new ReviewBeingDeletedError();
8746
+ onStarted?.(started.id);
8518
8747
  return runJob(workspaceId);
8519
8748
  }
8520
8749
  /**
@@ -8984,6 +9213,7 @@ async function recordKissRun(input) {
8984
9213
  workspaceId: workspace.id,
8985
9214
  target,
8986
9215
  env: agent.captureEnv(),
9216
+ purpose: "review",
8987
9217
  reviewType: "kiss",
8988
9218
  audience: input.audience
8989
9219
  });
@@ -9377,6 +9607,56 @@ function countByStatus(issues) {
9377
9607
  return counts;
9378
9608
  }
9379
9609
  //#endregion
9610
+ //#region ../../packages/review-harness/src/workspace/running_jobs.ts
9611
+ /**
9612
+ * What is being made right now, as opposed to what a record says.
9613
+ *
9614
+ * Its own module because two commands need the same answer and neither should have to import the
9615
+ * other to get it: `uninstall` refuses while anything is running, and deleting a review refuses
9616
+ * while that review is. Splitting it out is the same move `store_root.ts` made out of `store.ts`.
9617
+ */
9618
+ /**
9619
+ * Reviews being made at this moment.
9620
+ *
9621
+ * `liveClaim` rather than the job phase alone, and the distinction matters more here than anywhere:
9622
+ * a job whose driver was killed sits in a working phase for up to half an hour before the sweep
9623
+ * calls it stopped, and refusing over one of those would be refusing on behalf of a review that
9624
+ * nothing is making. The claim answers about a process rather than about a record.
9625
+ */
9626
+ function runningJobs(now = Date.now()) {
9627
+ return allJobs().filter((job) => !isTerminal(job)).flatMap((job) => {
9628
+ const claim = liveClaim(job.id, now);
9629
+ return claim ? [{
9630
+ job,
9631
+ claim
9632
+ }] : [];
9633
+ });
9634
+ }
9635
+ /**
9636
+ * Somebody is already bringing this review up to date, so a second update was not started.
9637
+ *
9638
+ * Refused rather than run alongside, because two updates cannot both be kept. Each reconciles
9639
+ * against its own copy of the review and saves the whole of it, so the one that finished last
9640
+ * silently replaced the other's result, and the snapshot kept for rolling back described the wrong
9641
+ * predecessor. Thrown from under the review's lock; see `claimCheckoutJob`.
9642
+ */
9643
+ var UpdateAlreadyRunningError = class extends Error {
9644
+ constructor(message = "This review is already being updated.") {
9645
+ super(message);
9646
+ this.name = "UpdateAlreadyRunningError";
9647
+ }
9648
+ };
9649
+ /**
9650
+ * The update being made to this review right now, if one is.
9651
+ *
9652
+ * Found in the job store rather than recorded on the workspace. An update's job is transient, and
9653
+ * an id written onto the review would be a second copy of "is one running" that could outlive the
9654
+ * answer. The newest where there are two, which only a race between two surfaces can produce.
9655
+ */
9656
+ function liveUpdateFor(reviewId, now = Date.now()) {
9657
+ return runningJobs(now).map((running) => running.job).filter((job) => job.workspaceId === reviewId && purposeOf(job) === "update").sort((a, b) => b.startedAt - a.startedAt || (a.id < b.id ? -1 : 1))[0] ?? null;
9658
+ }
9659
+ //#endregion
9380
9660
  //#region ../../packages/review-harness/src/workspace/pinned_checkout.ts
9381
9661
  /**
9382
9662
  * A checkout of one commit, for anything that needs to read the reviewed code.
@@ -9409,8 +9689,62 @@ var NoCheckoutError = class extends Error {};
9409
9689
  * look. Claimed inside the lock too, so there is no instant where a job record for this review
9410
9690
  * exists unclaimed and a deletion between the two would see nothing running.
9411
9691
  */
9412
- async function pinCheckout(workspace, sha) {
9692
+ async function pinCheckout(workspace, sha, held) {
9693
+ const owned = held === void 0;
9694
+ const claimed = held ?? await claimCheckoutJob(workspace, sha, { purpose: "ask" });
9695
+ const { claim } = claimed;
9696
+ let job = loadJob(claimed.job.id) ?? claimed.job;
9697
+ try {
9698
+ const path = await createWorktree({
9699
+ originRepoPath: workspace.repoPath,
9700
+ jobId: job.id,
9701
+ sha
9702
+ });
9703
+ job = {
9704
+ ...job,
9705
+ target: {
9706
+ ...job.target,
9707
+ sha
9708
+ },
9709
+ worktreePath: path
9710
+ };
9711
+ saveJob(job);
9712
+ return {
9713
+ path,
9714
+ job,
9715
+ claim
9716
+ };
9717
+ } catch (error) {
9718
+ if (owned) {
9719
+ saveJob(fail(job, error instanceof Error ? error.message : String(error)));
9720
+ claim.release();
9721
+ }
9722
+ throw error;
9723
+ }
9724
+ }
9725
+ /**
9726
+ * Register and claim the job a checkout will belong to, before the checkout exists.
9727
+ *
9728
+ * Separate from `pinCheckout` because an update needs its job long before it needs a directory: the
9729
+ * fetch and the conversation re-read come first and can take a while, and a job that only appeared
9730
+ * once they were over left the terminal and the page with nothing to show for them.
9731
+ *
9732
+ * Created under the review's lock, which is what makes the claim arrive before a deletion could
9733
+ * look. Claimed inside the lock too, so there is no instant where a job record for this review
9734
+ * exists unclaimed and a deletion between the two would see nothing running.
9735
+ *
9736
+ * The same lock carries a second invariant, for the same reason the first needed one:
9737
+ *
9738
+ * > **No update job for a review is created while another update of that review is claimed.**
9739
+ *
9740
+ * The check is inside the lock and creating an update job requires the lock, so the answer cannot
9741
+ * go stale between asking and creating. The lock is a file, so this holds between the page's button
9742
+ * in the daemon and `prreviewbuddy review --update` in a terminal, which is the pair that raced.
9743
+ * Only an update blocks an update: an analysis, a KISS run or an ask checkout is different work.
9744
+ */
9745
+ async function claimCheckoutJob(workspace, sha, fields) {
9413
9746
  const started = await withReviewLock(workspace.id, () => {
9747
+ if (fields.purpose === "update" && liveUpdateFor(workspace.id)) throw new UpdateAlreadyRunningError();
9414
9748
  const target = {
9415
9749
  originRepoPath: workspace.repoPath,
9416
9750
  branch: workspace.changeSet.headRef,
@@ -9420,7 +9754,8 @@ async function pinCheckout(workspace, sha) {
9420
9754
  const job = createJob({
9421
9755
  workspaceId: workspace.id,
9422
9756
  target,
9423
- env: captureEnv()
9757
+ env: captureEnv(),
9758
+ ...fields
9424
9759
  });
9425
9760
  saveJob(job);
9426
9761
  const claim = claimJob(job.id);
@@ -9431,29 +9766,7 @@ async function pinCheckout(workspace, sha) {
9431
9766
  };
9432
9767
  });
9433
9768
  if (isBusy(started)) throw new ReviewBeingDeletedError();
9434
- let { job } = started;
9435
- const { claim } = started;
9436
- try {
9437
- const path = await createWorktree({
9438
- originRepoPath: workspace.repoPath,
9439
- jobId: job.id,
9440
- sha
9441
- });
9442
- job = {
9443
- ...job,
9444
- worktreePath: path
9445
- };
9446
- saveJob(job);
9447
- return {
9448
- path,
9449
- job,
9450
- claim
9451
- };
9452
- } catch (error) {
9453
- saveJob(fail(job, error instanceof Error ? error.message : String(error)));
9454
- claim.release();
9455
- throw error;
9456
- }
9769
+ return started;
9457
9770
  }
9458
9771
  /**
9459
9772
  * Which checkout each open review's questions are being answered against.
@@ -9722,27 +10035,45 @@ var FETCH_TIMEOUT_MS = 6e4;
9722
10035
  */
9723
10036
  async function updateReview(workspace, runners = {}) {
9724
10037
  const reviewedSha = workspace.session.metadata?.commitSha || null;
10038
+ const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
10039
+ purpose: "update",
10040
+ ...workspace.prContext ? { conversation: {
10041
+ kind: "attached",
10042
+ context: workspace.prContext
10043
+ } } : {}
10044
+ });
10045
+ runners.onStarted?.(held.job.id);
10046
+ const current = () => loadJob(held.job.id) ?? held.job;
10047
+ const step = (phase) => saveJob(advance(current(), phase));
10048
+ try {
10049
+ const outcome = await bringForward(workspace, runners, reviewedSha, held, step);
10050
+ saveJob(outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message));
10051
+ return outcome;
10052
+ } catch (error) {
10053
+ saveJob(fail(current(), error instanceof Error ? error.message : String(error)));
10054
+ throw error;
10055
+ } finally {
10056
+ held.claim.release();
10057
+ }
10058
+ }
10059
+ async function bringForward(workspace, runners, reviewedSha, held, step) {
9725
10060
  const remote = await remoteHead(workspace);
9726
10061
  if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, noRemoteHeadMessage(workspace));
10062
+ if (workspace.prContext) step("conversation");
9727
10063
  const conversationOk = await reReadConversation(workspace, runners.runRefresh);
9728
10064
  if (reviewedSha && !await commitPresent(workspace.repoPath, reviewedSha)) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
9729
10065
  if (!reviewedSha || reviewedSha === remote.sha) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
10066
+ step("context");
9730
10067
  let worktreePath;
9731
- let job;
9732
- let claim;
9733
10068
  try {
9734
- ({path: worktreePath, job, claim} = await pinCheckout(workspace, remote.sha));
10069
+ ({path: worktreePath} = await pinCheckout(workspace, remote.sha, held));
9735
10070
  } catch (error) {
9736
10071
  if (error instanceof ReviewBeingDeletedError) throw error;
9737
10072
  return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the new commits could not be prepared, so nothing was reassessed.");
9738
10073
  }
9739
- try {
9740
- return await reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath);
9741
- } finally {
9742
- saveJob(advance(job, "done"));
9743
- }
10074
+ return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id);
9744
10075
  }
9745
- async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath) {
10076
+ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId) {
9746
10077
  const newHead = remote.sha;
9747
10078
  let delta;
9748
10079
  try {
@@ -9756,10 +10087,19 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
9756
10087
  }
9757
10088
  const previous = workspace.session.result;
9758
10089
  if (!previous) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "no-result", "This review has no analysis to bring up to date.");
10090
+ step("analysing");
10091
+ const onProgress = (text) => {
10092
+ const current = loadJob(jobId);
10093
+ if (current) saveJob({
10094
+ ...current,
10095
+ progress: text,
10096
+ updatedAt: Date.now()
10097
+ });
10098
+ };
9759
10099
  let raw;
9760
10100
  try {
9761
10101
  const prompt = buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath);
9762
- raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace)) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace));
10102
+ raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace), onProgress) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace), onProgress);
9763
10103
  } catch (error) {
9764
10104
  return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", error instanceof RefreshUnavailableError ? error.message : "The agent could not finish reassessing the review, so the findings are exactly as they were.");
9765
10105
  }
@@ -10083,7 +10423,7 @@ ${buildReconciliationInstructions({
10083
10423
  * that captured the daemon's environment instead was reconciling one review's findings with
10084
10424
  * whichever agent and account the workspace server happened to be started under.
10085
10425
  */
10086
- async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env) {
10426
+ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgress) {
10087
10427
  try {
10088
10428
  return await withUsageRecorded(workspaceId, "update", env, (onUsage) => agentFor(env).run({
10089
10429
  capability: "read-code",
@@ -10092,6 +10432,7 @@ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env) {
10092
10432
  originRepoPath,
10093
10433
  env,
10094
10434
  timeoutMs: UPDATE_TIMEOUT_MS,
10435
+ onProgress,
10095
10436
  onUsage
10096
10437
  }));
10097
10438
  } catch (error) {
@@ -10569,32 +10910,6 @@ async function discardJob(job, options = {}) {
10569
10910
  return { removed: true };
10570
10911
  }
10571
10912
  //#endregion
10572
- //#region ../../packages/review-harness/src/workspace/running_jobs.ts
10573
- /**
10574
- * What is being made right now, as opposed to what a record says.
10575
- *
10576
- * Its own module because two commands need the same answer and neither should have to import the
10577
- * other to get it: `uninstall` refuses while anything is running, and deleting a review refuses
10578
- * while that review is. Splitting it out is the same move `store_root.ts` made out of `store.ts`.
10579
- */
10580
- /**
10581
- * Reviews being made at this moment.
10582
- *
10583
- * `liveClaim` rather than the job phase alone, and the distinction matters more here than anywhere:
10584
- * a job whose driver was killed sits in a working phase for up to half an hour before the sweep
10585
- * calls it stopped, and refusing over one of those would be refusing on behalf of a review that
10586
- * nothing is making. The claim answers about a process rather than about a record.
10587
- */
10588
- function runningJobs(now = Date.now()) {
10589
- return allJobs().filter((job) => !isTerminal(job)).flatMap((job) => {
10590
- const claim = liveClaim(job.id, now);
10591
- return claim ? [{
10592
- job,
10593
- claim
10594
- }] : [];
10595
- });
10596
- }
10597
- //#endregion
10598
10913
  //#region ../../packages/review-harness/src/workspace/delete_review.ts
10599
10914
  /**
10600
10915
  * Removing one review, or everything on one card.
@@ -10762,4 +11077,4 @@ function relativeTime(ms) {
10762
11077
  return `${days} day${days === 1 ? "" : "s"} ago`;
10763
11078
  }
10764
11079
  //#endregion
10765
- export { allJobs as $, agentEnvOf as A, processDiscussion as At, BUILD_VERSION as B, STORE_ROOT as Bt, statedWorkspacePort as C, touchWorkspace as Ct, refreshPrContext as D, issuesOutstanding as Dt, updateReview as E, isQuestionOutstanding as Et, startJob as F, agentIdOf as Ft, forgeResolver as G, detectAgents as Gt, feedbackUrl as H, DEFAULT_AGENT_ID as Ht, readEvents as I, clearAgentPreference as It, MANAGED_ROOT as J, fillFileUrlTemplate as K, AgentCancelledError as Kt, record as L, readAgentPreference as Lt, startKissJob as M, resolveTarget as Mt, reanalyseReview as N, displayRef as Nt, isUnchanged as O, questionsOutstanding as Ot, runJob as P, git as Pt, checkFreshness as Q, recordTelemetryUploadConsent as R, writeAgentPreference as Rt, resolveWorkspacePort as S, summarise as St, RefreshUnavailableError as T, isIssueOutstanding as Tt, withUsageRecorded as U, agentById as Ut, PACKAGE_NAME as V, AGENT_IDS as Vt, EXPLAIN_SIMPLY_PROMPT as W, agentFor as Wt, removeWorktree as X, readMarker as Y, checkCodeFreshness as Z, writeServerRecord as _, recentReviewGroups as _t, liveJobsFor as a, progressSteps as at, MIN_WORKSPACE_PORT as b, reviewedRepositories as bt, discardJob as c, followedRefName as ct, clearServerRecord as d, lineageKeyFor as dt, fail as et, ensureServer as f, lineagePosition as ft, workspaceUrl as g, previousKissResult as gt, stopServer as h, positionInLineage as ht, lineageIds as i, PHASES as it, recordKissRun as j, describeAuthorship as jt, askCheckout as k, reviewerDispositions as kt, readIndexToken as l, groupByLineage as lt, reviewsUrl as m, matchingWorkspaceIds as mt, deleteLineage as n, loadJob as nt, wasBlocked as o, clearClaim as ot, readServerRecord as p, loadWorkspace as pt, ReviewBeingDeletedError as q, AgentUnavailableError as qt, deleteReview as r, saveJob as rt, runningJobs as s, isClaimed as st, relativeTime as t, isTerminal as tt, bootstrapUrl as u, latestKissRun as ut, DEFAULT_WORKSPACE_PORT as v, recentWorkspaces as vt, writePortPreference as w, doneVerb as wt, parseWorkspacePort as x, saveWorkspace as xt, MAX_WORKSPACE_PORT as y, reviewedCommit as yt, telemetryUploadConsent as z, CONFIG_PATH as zt };
11080
+ export { checkCodeFreshness as $, AgentCancelledError as $t, liveUpdateFor as A, workspaceStamps as At, recordTelemetryUploadConsent as B, displayRef as Bt, writePortPreference as C, recentWorkspaces as Ct, isUnchanged as D, summarise as Dt, refreshPrContext as E, saveWorkspace as Et, reanalyseReview as F, questionsOutstanding as Ft, withUsageRecorded as G, writeAgentPreference as Gt, BUILD_VERSION as H, agentIdOf as Ht, runJob as I, reviewerDispositions as It, fillFileUrlTemplate as J, AGENT_IDS as Jt, EXPLAIN_SIMPLY_PROMPT as K, CONFIG_PATH as Kt, startJob as L, processDiscussion as Lt, agentEnvOf as M, isIssueOutstanding as Mt, recordKissRun as N, isQuestionOutstanding as Nt, askCheckout as O, touchWorkspace as Ot, startKissJob as P, issuesOutstanding as Pt, removeWorktree as Q, detectAgents as Qt, readEvents as R, describeAuthorship as Rt, statedWorkspacePort as S, recentReviewGroups as St, updateReview as T, reviewedRepositories as Tt, PACKAGE_NAME as U, clearAgentPreference as Ut, telemetryUploadConsent as V, git as Vt, feedbackUrl as W, readAgentPreference as Wt, MANAGED_ROOT as X, agentById as Xt, ReviewBeingDeletedError as Y, DEFAULT_AGENT_ID as Yt, readMarker as Z, agentFor as Zt, DEFAULT_WORKSPACE_PORT as _, lineagePosition as _t, liveJobsFor as a, loadJob as at, parseWorkspacePort as b, positionInLineage as bt, readIndexToken as c, phasesFor as ct, ensureServer as d, clearClaim as dt, AgentUnavailableError as en, checkFreshness as et, readServerRecord as f, isClaimed as ft, writeServerRecord as g, lineageKeyFor as gt, workspaceUrl as h, latestKissRun as ht, lineageIds as i, isTerminal as it, runningJobs as j, doneVerb as jt, UpdateAlreadyRunningError as k, workspaceRevision as kt, bootstrapUrl as l, progressSteps as lt, stopServer as m, groupByLineage as mt, deleteLineage as n, endedAt as nt, wasBlocked as o, saveJob as ot, reviewsUrl as p, followedRefName as pt, forgeResolver as q, STORE_ROOT as qt, deleteReview as r, fail as rt, discardJob as s, PHASES as st, relativeTime as t, allJobs as tt, clearServerRecord as u, purposeOf as ut, MAX_WORKSPACE_PORT as v, loadWorkspace as vt, RefreshUnavailableError as w, reviewedCommit as wt, resolveWorkspacePort as x, previousKissResult as xt, MIN_WORKSPACE_PORT as y, matchingWorkspaceIds as yt, record as z, resolveTarget as zt };