prreviewbuddy 0.25.7 → 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 });
@@ -3869,149 +4028,416 @@ function pruneOldWorkspaces() {
3869
4028
  } catch {}
3870
4029
  }
3871
4030
  //#endregion
3872
- //#region ../../packages/review-harness/src/workspace/job_progress.ts
3873
- /**
3874
- * The phases that do work, in the order they run. `done` and `failed` are outcomes, not work.
3875
- *
3876
- * Declared here rather than in `job_store.ts`, which is where it used to live, because this module
3877
- * is imported by the browser bundle and that one opens files. Everything here is a string table
3878
- * and a fold over a record; the store reads this rather than the other way round, so the client
3879
- * can share the phase model without dragging `node:fs` in behind it. The type still comes from the
3880
- * store, which is harmless: types are erased.
3881
- */
3882
- var WORK_PHASES = [
3883
- "preparing",
3884
- "context",
3885
- "conversation",
3886
- "analysing"
3887
- ];
3888
- /**
3889
- * Two forms per phase, because a finished step and a running one are different sentences.
3890
- *
3891
- * `done` and `failed` are outcomes rather than work, so they never appear in a checklist; they
3892
- * are named anyway because `JobPhase` includes them and a partial table would make every lookup
3893
- * a possible undefined.
3894
- */
3895
- var PHASES = {
3896
- preparing: {
3897
- doing: "Preparing isolated checkout",
3898
- done: "Prepared isolated checkout"
3899
- },
3900
- context: {
3901
- doing: "Reading what changed",
3902
- done: "Read what changed"
3903
- },
3904
- conversation: {
3905
- doing: "Reading pull request conversation",
3906
- done: "Read pull request conversation"
3907
- },
3908
- analysing: {
3909
- doing: "Analysing the change",
3910
- done: "Analysed the change"
3911
- },
3912
- done: {
3913
- doing: "Finishing",
3914
- done: "Finished"
3915
- },
3916
- failed: {
3917
- doing: "Stopped",
3918
- done: "Stopped"
3919
- }
3920
- };
4031
+ //#region ../../packages/review-harness/src/workspace/exclusive_claim.ts
3921
4032
  /**
3922
- * Whether a phase is part of *this* review, rather than part of the pipeline in general.
3923
- *
3924
- * Only `conversation` is ever optional, and reviewing a branch with no pull request is an ordinary
3925
- * supported thing to do rather than a degraded version of something else. Ticking "Read pull
3926
- * request conversation" on such a review claims a step that did not happen and makes a complete
3927
- * review look like an incomplete one; printing "No pull request found" instead is no better,
3928
- * because a line in a checklist reads as something missing whatever words are in it.
4033
+ * At most one holder of a name at a time, across processes, on this machine.
3929
4034
  *
3930
- * So the rule is that an optional stage which did not apply is not rendered at all:
4035
+ * Extracted from `job_claim.ts`, which had written all of this for one caller. A second caller then
4036
+ * needed the same thing under a different name -- see `review_lock.ts` -- and the alternative was a
4037
+ * second concurrency system beside a working one, which is how two mechanisms come to disagree
4038
+ * about who holds what.
3931
4039
  *
3932
- * - A pull request review always shows it. It is the point of that review, and it is known before
3933
- * the phase runs, so the line never appears late.
3934
- * - A branch review shows it only once a conversation was actually attached, which happens when the
3935
- * branch turns out to have a request on a forge we read. Real work, worth saying.
3936
- * - Everything else — no request, an unsupported host, `gh` missing — shows nothing.
4040
+ * The mechanism, and the reasoning behind each half of it, lives here. What a particular claim
4041
+ * *means* stays with the module that takes it: "at most one driver owns a job" is a statement about
4042
+ * jobs, not about `link()`.
3937
4043
  *
3938
- * The absent cases are still explained, once, at the end: the CLI's closing summary and the page's
3939
- * `conversationNote` both say the review was made without the conversation and why. That is the
3940
- * right place for it, because by then it is a fact about the finished review rather than a gap in
3941
- * a list of things still happening.
4044
+ * Never holds a credential, and holds nothing about the thing it names: a claim is a pid, a host,
4045
+ * and two timestamps.
3942
4046
  */
3943
- function appliesTo(job, phase) {
3944
- if (phase !== "conversation") return true;
3945
- return job.target.pullRequest !== void 0 || job.conversation?.kind === "attached";
3946
- }
3947
4047
  /**
3948
- * Every step this review runs, in order, with what has become of each.
4048
+ * One directory for every kind of claim, keyed by name.
3949
4049
  *
3950
- * Read from `job.completed` rather than inferred from the position of `job.phase` in the list.
3951
- * The job records a phase as complete when it is *left*, so a run that died inside a phase leaves
3952
- * it uncompleted which is the honest shape of that, and the thing a retry resumes from. Working
3953
- * it out from ordering instead would tick a step the job never finished.
4050
+ * Kept out of `jobs/`, where `allJobs` reads every `.json` and parses it as a `ReviewJob`: a claim
4051
+ * file there would be swept as a job. Keys from different callers share the directory and are kept
4052
+ * apart by shape -- a job claim is a UUID, a review lock is `review-<id>` -- so neither can name
4053
+ * the other's file by accident.
3954
4054
  */
3955
- function progressSteps(job) {
3956
- const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
3957
- return WORK_PHASES.filter((phase) => appliesTo(job, phase)).map((phase) => {
3958
- const complete = job.completed.includes(phase) || job.phase === "done";
3959
- const failed = failedAt === phase;
3960
- const active = !complete && !failed && job.phase === phase;
3961
- const state = failed ? "failed" : complete ? "complete" : active ? "active" : "pending";
3962
- const label = complete ? PHASES[phase].done : PHASES[phase].doing;
3963
- const note = active && job.progress && !restates(job.progress, label) ? job.progress : void 0;
3964
- return {
3965
- phase,
3966
- label,
3967
- state,
3968
- ...note ? { note } : {}
3969
- };
3970
- });
4055
+ var CLAIMS_DIR = join(STORE_ROOT, "claims");
4056
+ /** Comfortably inside the lease, so one slow tick is never enough to lose a claim that is held. */
4057
+ var CLAIM_RENEW_MS = 3e4;
4058
+ function claimPath(key) {
4059
+ return join(CLAIMS_DIR, `${key}.json`);
3971
4060
  }
3972
- /** Same words as the phase heading, give or take the articles and the trailing full stop. */
3973
- function restates(note, label) {
3974
- const bare = (text) => text.toLowerCase().replace(/\bthe\b/g, "").replace(/[^a-z]/g, "");
3975
- return bare(note) === bare(label);
4061
+ function readClaim(key) {
4062
+ try {
4063
+ return JSON.parse(readFileSync(claimPath(key), "utf8"));
4064
+ } catch {
4065
+ return null;
4066
+ }
3976
4067
  }
3977
- //#endregion
3978
- //#region ../../packages/review-harness/src/workspace/job_store.ts
3979
4068
  /**
3980
- * What a review is doing right now, kept apart from the review itself.
4069
+ * Whether the process behind a claim still exists.
3981
4070
  *
3982
- * Separate from `StoredWorkspace` for two reasons that both bite later if they are fused. Pruning
3983
- * a worktree edits a job and must never be a write to a review, or a sweep on a timer becomes a
3984
- * process that rewrites reviews. And a job is finished business within hours while a review is
3985
- * kept for thirty days, so one record with two lifetimes would have to encode both.
4071
+ * Signal 0 checks for a process without touching it: `ESRCH` means gone, and `EPERM` means it is
4072
+ * there but owned by somebody else, which is still there. Only meaningful for a claim written on
4073
+ * this machine, and `~/.prreviewbuddy` can sit on a synced home directory, so a claim from another
4074
+ * host is left to the lease alone rather than judged against a pid that means nothing here.
3986
4075
  */
4076
+ function ownerAlive(claim) {
4077
+ if (claim.host !== hostname()) return true;
4078
+ try {
4079
+ process.kill(claim.pid, 0);
4080
+ return true;
4081
+ } catch (error) {
4082
+ return error.code === "EPERM";
4083
+ }
4084
+ }
3987
4085
  /**
3988
- * The phases that do work, in the order they run. `done` and `failed` are outcomes, not work.
4086
+ * The claim on this key, if one is genuinely held.
3989
4087
  *
3990
- * Defined in `job_progress.ts` and re-exported here, where every existing caller already looks for
3991
- * it. It lives there because the workspace page's client bundle needs the phase model and must not
3992
- * import this module, which opens files.
4088
+ * Both conditions, because each covers the other's blind spot. A pid check recovers from a killed
4089
+ * holder at once but can be fooled by a reused pid; a lease cannot be fooled but is always late by
4090
+ * up to its own length. Requiring both means a reused pid with a stale lease reads as free, and a
4091
+ * dead pid with a fresh lease reads as free immediately.
3993
4092
  */
3994
- var JOBS_DIR = join(STORE_ROOT, "jobs");
4093
+ function liveClaim$1(key, now = Date.now()) {
4094
+ const claim = readClaim(key);
4095
+ if (!claim) return null;
4096
+ if (now - claim.renewedAt >= 18e4) return null;
4097
+ if (!ownerAlive(claim)) return null;
4098
+ return claim;
4099
+ }
3995
4100
  /**
3996
- * Whether this job asks the forge for the conversation, or reads what is already on the workspace.
4101
+ * Take the claim on a key, or return null because somebody else holds it.
3997
4102
  *
3998
- * Every job runs every phase, including KISS: the phase list is the same for both lenses, so
3999
- * `resumeFrom` cannot mean two things and a resumed job cannot skip work its record says it did.
4000
- * What differs is what `conversation` does. A KISS run is started against a change a standard run
4001
- * has usually already imported the conversation for, minutes earlier and at the same commit, so
4002
- * asking the forge again would be a second network trip for facts already on disk.
4103
+ * Synchronous from the check to the create, with no `await` between them, so two calls inside one
4104
+ * process cannot both pass: this keeps the property a module-level `Set` had, and adds the one it
4105
+ * lacked.
4003
4106
  *
4004
- * Standard runs always ask, including a reanalyse, because a reanalyse is a deliberate "do this
4005
- * again" and the conversation is the part most likely to have moved since.
4107
+ * Across processes the create is `link`, which fails with `EEXIST` if the name is taken and does so
4108
+ * atomically, unlike a write with a prior existence check. A stale claim is unlinked first, and
4109
+ * only if it is still the same stale claim that was read a line earlier, identified by `holder`.
4110
+ * The residual race is two processes stealing the *same* stale claim within the same few
4111
+ * microseconds, where the loser can delete the winner's fresh file. Left rather than solved: it
4112
+ * needs a crashed holder and two attempts fired at the same instant, and `recordFailure` already
4113
+ * refuses to write over a job record that moved underneath it, so the cost of losing that race is
4114
+ * duplicated work rather than a corrupted review.
4115
+ *
4116
+ * **Never waits.** A caller that cannot have the key is told so immediately. Queueing would turn a
4117
+ * deletion racing an analysis into a deletion that happens twenty minutes later, when the person
4118
+ * who asked for it has stopped looking.
4006
4119
  */
4007
- function reusesImportedConversation(job) {
4008
- return job.reviewType === "kiss";
4009
- }
4010
- function jobPath(id) {
4011
- return join(JOBS_DIR, `${id}.json`);
4012
- }
4013
- function createJob(input) {
4014
- const now = Date.now();
4120
+ function acquire(key, now = Date.now()) {
4121
+ const existing = readClaim(key);
4122
+ if (existing) {
4123
+ if (liveClaim$1(key, now)) return null;
4124
+ const current = readClaim(key);
4125
+ if (current && current.holder !== existing.holder) return null;
4126
+ try {
4127
+ unlinkSync(claimPath(key));
4128
+ } catch {}
4129
+ }
4130
+ const claim = {
4131
+ key,
4132
+ holder: randomUUID(),
4133
+ pid: process.pid,
4134
+ host: hostname(),
4135
+ acquiredAt: now,
4136
+ renewedAt: now
4137
+ };
4138
+ mkdirSync(CLAIMS_DIR, { recursive: true });
4139
+ const staging = join(CLAIMS_DIR, `.${claim.holder}.tmp`);
4140
+ writeFileSync(staging, JSON.stringify(claim), { mode: 384 });
4141
+ try {
4142
+ linkSync(staging, claimPath(key));
4143
+ } catch {
4144
+ return null;
4145
+ } finally {
4146
+ rmSync(staging, { force: true });
4147
+ }
4148
+ return startHolding(claim);
4149
+ }
4150
+ /**
4151
+ * Keep the claim fresh for as long as the work runs, and give it up afterwards.
4152
+ *
4153
+ * The timer is unreferenced on purpose. A heartbeat must never be the reason a process stays alive:
4154
+ * the CLI is held open by the await on the job and the workspace server by its socket, and if
4155
+ * neither is true any more then this claim is exactly the kind that should be allowed to expire.
4156
+ *
4157
+ * A renewal that finds the claim *gone* writes it back rather than giving up. A holder whose event
4158
+ * loop was blocked past the lease -- parsing a very large diff, say -- can have its claim judged
4159
+ * expired and swept while it is still perfectly alive, and a holder that then quietly stopped
4160
+ * renewing would leave the key looking free for the rest of its run. Nobody holds the file, so
4161
+ * taking it again is the true statement.
4162
+ *
4163
+ * A renewal that finds the claim held by somebody *else* stops rather than writing over it. By then
4164
+ * the other holder is the real one, and reasserting ownership here would produce the two holders
4165
+ * this whole file exists to prevent.
4166
+ */
4167
+ function startHolding(claim) {
4168
+ let held = claim;
4169
+ const timer = setInterval(() => {
4170
+ const current = readClaim(held.key);
4171
+ if (current && current.holder !== held.holder) {
4172
+ clearInterval(timer);
4173
+ return;
4174
+ }
4175
+ held = {
4176
+ ...held,
4177
+ renewedAt: Date.now()
4178
+ };
4179
+ try {
4180
+ writeFileSync(claimPath(held.key), JSON.stringify(held), { mode: 384 });
4181
+ } catch {}
4182
+ }, CLAIM_RENEW_MS);
4183
+ timer.unref?.();
4184
+ return {
4185
+ get claim() {
4186
+ return held;
4187
+ },
4188
+ release() {
4189
+ clearInterval(timer);
4190
+ const current = readClaim(held.key);
4191
+ if (current && current.holder !== held.holder) return;
4192
+ try {
4193
+ unlinkSync(claimPath(held.key));
4194
+ } catch {}
4195
+ }
4196
+ };
4197
+ }
4198
+ /** Drop a claim without holding it first. For a key being destroyed, and for tests. */
4199
+ function clearClaim$1(key) {
4200
+ try {
4201
+ unlinkSync(claimPath(key));
4202
+ } catch {}
4203
+ }
4204
+ //#endregion
4205
+ //#region ../../packages/review-harness/src/workspace/job_claim.ts
4206
+ /**
4207
+ * Who is driving a job right now, recorded where another process can see it.
4208
+ *
4209
+ * The invariant this exists to state: **at most one driver owns a job at a time.** `runJob` used to
4210
+ * enforce that with a module-level `Set`, which was correct when one process called it and became
4211
+ * silently wrong the moment three did -- the MCP server, the workspace server's Retry route, and
4212
+ * the CLI. A guard that is invisible across processes is not a guard against the case that
4213
+ * actually happens, which is two surfaces on one machine reaching the same job.
4214
+ *
4215
+ * Idempotence at each collision point was the alternative, and it was rejected: it would have to be
4216
+ * rebuilt separately for `createWorktree`, for context building, for the agent spawn and for
4217
+ * applying the result, and each one would only ever be as good as the last person who remembered.
4218
+ * One claim covers the whole lifecycle.
4219
+ *
4220
+ * The mechanism is `exclusive_claim.ts`, and this file is now the naming: a job is claimed under
4221
+ * its own id, so `claims/<jobId>.json` is where it has always been. The mechanism moved out when a
4222
+ * review lock needed the same guarantee under a different name; what stays here is what a job claim
4223
+ * means, which is the part that is about jobs rather than about `link()`.
4224
+ */
4225
+ /** The claim on this job, if a driver genuinely holds it. */
4226
+ function liveClaim(jobId, now = Date.now()) {
4227
+ return liveClaim$1(jobId, now);
4228
+ }
4229
+ /** Whether a driver owns this job right now. The question every other module actually asks. */
4230
+ function isClaimed(jobId, now = Date.now()) {
4231
+ return liveClaim(jobId, now) !== null;
4232
+ }
4233
+ /**
4234
+ * Take the claim on a job, or return null because another driver holds it.
4235
+ *
4236
+ * Two retries fired together, a retry racing the run `startJob` already kicked off, or a retry
4237
+ * racing a driver the sweep wrongly called stopped: all of them land here rather than both reaching
4238
+ * `createWorktree` or the analysis spawn.
4239
+ */
4240
+ function claimJob(jobId, now = Date.now()) {
4241
+ return acquire(jobId, now);
4242
+ }
4243
+ /** Drop a job's claim without holding it first. For a job being deleted, and for tests. */
4244
+ function clearClaim(jobId) {
4245
+ clearClaim$1(jobId);
4246
+ }
4247
+ //#endregion
4248
+ //#region ../../packages/review-harness/src/workspace/job_progress.ts
4249
+ /**
4250
+ * The phases that do work, in the order they run. `done` and `failed` are outcomes, not work.
4251
+ *
4252
+ * Declared here rather than in `job_store.ts`, which is where it used to live, because this module
4253
+ * is imported by the browser bundle and that one opens files. Everything here is a string table
4254
+ * and a fold over a record; the store reads this rather than the other way round, so the client
4255
+ * can share the phase model without dragging `node:fs` in behind it. The type still comes from the
4256
+ * store, which is harmless: types are erased.
4257
+ */
4258
+ var WORK_PHASES = [
4259
+ "preparing",
4260
+ "context",
4261
+ "conversation",
4262
+ "analysing"
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
+ }
4268
+ /**
4269
+ * Two forms per phase, because a finished step and a running one are different sentences.
4270
+ *
4271
+ * `done` and `failed` are outcomes rather than work, so they never appear in a checklist; they
4272
+ * are named anyway because `JobPhase` includes them and a partial table would make every lookup
4273
+ * a possible undefined.
4274
+ */
4275
+ var PHASES = {
4276
+ preparing: {
4277
+ doing: "Preparing isolated checkout",
4278
+ done: "Prepared isolated checkout"
4279
+ },
4280
+ context: {
4281
+ doing: "Reading what changed",
4282
+ done: "Read what changed"
4283
+ },
4284
+ conversation: {
4285
+ doing: "Reading pull request conversation",
4286
+ done: "Read pull request conversation"
4287
+ },
4288
+ analysing: {
4289
+ doing: "Analysing the change",
4290
+ done: "Analysed the change"
4291
+ },
4292
+ done: {
4293
+ doing: "Finishing",
4294
+ done: "Finished"
4295
+ },
4296
+ failed: {
4297
+ doing: "Stopped",
4298
+ done: "Stopped"
4299
+ }
4300
+ };
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
+ /**
4345
+ * Whether a phase is part of *this* review, rather than part of the pipeline in general.
4346
+ *
4347
+ * Only `conversation` is ever optional, and reviewing a branch with no pull request is an ordinary
4348
+ * supported thing to do rather than a degraded version of something else. Ticking "Read pull
4349
+ * request conversation" on such a review claims a step that did not happen and makes a complete
4350
+ * review look like an incomplete one; printing "No pull request found" instead is no better,
4351
+ * because a line in a checklist reads as something missing whatever words are in it.
4352
+ *
4353
+ * So the rule is that an optional stage which did not apply is not rendered at all:
4354
+ *
4355
+ * - A pull request review always shows it. It is the point of that review, and it is known before
4356
+ * the phase runs, so the line never appears late.
4357
+ * - A branch review shows it only once a conversation was actually attached, which happens when the
4358
+ * branch turns out to have a request on a forge we read. Real work, worth saying.
4359
+ * - Everything else — no request, an unsupported host, `gh` missing — shows nothing.
4360
+ *
4361
+ * The absent cases are still explained, once, at the end: the CLI's closing summary and the page's
4362
+ * `conversationNote` both say the review was made without the conversation and why. That is the
4363
+ * right place for it, because by then it is a fact about the finished review rather than a gap in
4364
+ * a list of things still happening.
4365
+ */
4366
+ function appliesTo(job, phase) {
4367
+ if (phase !== "conversation") return true;
4368
+ return job.target.pullRequest !== void 0 || job.conversation?.kind === "attached";
4369
+ }
4370
+ /**
4371
+ * Every step this review runs, in order, with what has become of each.
4372
+ *
4373
+ * Read from `job.completed` rather than inferred from the position of `job.phase` in the list.
4374
+ * The job records a phase as complete when it is *left*, so a run that died inside a phase leaves
4375
+ * it uncompleted — which is the honest shape of that, and the thing a retry resumes from. Working
4376
+ * it out from ordering instead would tick a step the job never finished.
4377
+ */
4378
+ function progressSteps(job) {
4379
+ const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
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";
4385
+ const failed = failedAt === phase;
4386
+ const active = !complete && !failed && job.phase === phase;
4387
+ const state = failed ? "failed" : complete ? "complete" : active ? "active" : "pending";
4388
+ const label = complete ? names[phase].done : names[phase].doing;
4389
+ const note = active && job.progress && !restates(job.progress, label) ? job.progress : void 0;
4390
+ return {
4391
+ phase,
4392
+ label,
4393
+ state,
4394
+ ...note ? { note } : {}
4395
+ };
4396
+ });
4397
+ }
4398
+ /** Same words as the phase heading, give or take the articles and the trailing full stop. */
4399
+ function restates(note, label) {
4400
+ const bare = (text) => text.toLowerCase().replace(/\bthe\b/g, "").replace(/[^a-z]/g, "");
4401
+ return bare(note) === bare(label);
4402
+ }
4403
+ //#endregion
4404
+ //#region ../../packages/review-harness/src/workspace/job_store.ts
4405
+ /**
4406
+ * What a review is doing right now, kept apart from the review itself.
4407
+ *
4408
+ * Separate from `StoredWorkspace` for two reasons that both bite later if they are fused. Pruning
4409
+ * a worktree edits a job and must never be a write to a review, or a sweep on a timer becomes a
4410
+ * process that rewrites reviews. And a job is finished business within hours while a review is
4411
+ * kept for thirty days, so one record with two lifetimes would have to encode both.
4412
+ */
4413
+ /**
4414
+ * The phases that do work, in the order they run. `done` and `failed` are outcomes, not work.
4415
+ *
4416
+ * Defined in `job_progress.ts` and re-exported here, where every existing caller already looks for
4417
+ * it. It lives there because the workspace page's client bundle needs the phase model and must not
4418
+ * import this module, which opens files.
4419
+ */
4420
+ var JOBS_DIR = join(STORE_ROOT, "jobs");
4421
+ /**
4422
+ * Whether this job asks the forge for the conversation, or reads what is already on the workspace.
4423
+ *
4424
+ * Every job runs every phase, including KISS: the phase list is the same for both lenses, so
4425
+ * `resumeFrom` cannot mean two things and a resumed job cannot skip work its record says it did.
4426
+ * What differs is what `conversation` does. A KISS run is started against a change a standard run
4427
+ * has usually already imported the conversation for, minutes earlier and at the same commit, so
4428
+ * asking the forge again would be a second network trip for facts already on disk.
4429
+ *
4430
+ * Standard runs always ask, including a reanalyse, because a reanalyse is a deliberate "do this
4431
+ * again" and the conversation is the part most likely to have moved since.
4432
+ */
4433
+ function reusesImportedConversation(job) {
4434
+ return job.reviewType === "kiss";
4435
+ }
4436
+ function jobPath(id) {
4437
+ return join(JOBS_DIR, `${id}.json`);
4438
+ }
4439
+ function createJob(input) {
4440
+ const now = Date.now();
4015
4441
  return {
4016
4442
  id: randomUUID(),
4017
4443
  phase: "preparing",
@@ -4097,23 +4523,48 @@ function fail(job, message, kind, detail) {
4097
4523
  };
4098
4524
  }
4099
4525
  /**
4100
- * Record a failure, without overwriting a record that has moved on since this run last saw it.
4526
+ * Record a failure, unless somebody else is driving this job now.
4527
+ *
4528
+ * A run only reaches this after an `await` on the git call or the agent spawn that just rejected,
4529
+ * and whatever this run's own `job` variable holds by then is a snapshot from before that. If the
4530
+ * run that owns the job is no longer this one -- this one's claim lapsed and another driver picked
4531
+ * the job up -- saving this run's failure would take a job that is running fine and record it as
4532
+ * failed, and nothing downstream could tell the difference from a real one: `saveJob` writes
4533
+ * whatever it is given.
4101
4534
  *
4102
- * A run only reaches this after an `await` on the git call or the Claude Code spawn that just
4103
- * rejected, and whatever this run's own `job` variable holds by then is a snapshot from before
4104
- * that. If some other write landed on disk in the meantime -- another run this one should have
4105
- * been refused for racing, had the guard in `runJob` worked, or simply one that started later and
4106
- * is now the run actually driving this job -- saving this run's failure over it would take a job
4107
- * that is running fine and record it as failed, and nothing downstream could tell the difference
4108
- * from a real one: `saveJob` writes whatever it is given.
4535
+ * The question is asked of the claim, which is who is driving, and not of `updatedAt`, which is
4536
+ * only when the record last moved. Those two came apart the moment the analysis began streaming
4537
+ * progress: `runAnalysisFor` writes every line the agent reports onto the record with a fresh
4538
+ * `updatedAt` and does not hand the new copy back, so a driver's own commentary made its own
4539
+ * snapshot look stale and it declined to record its own failure. The job stayed at `analysing` for
4540
+ * ever, the page kept the running rail over it, and the CLI printed `Review ready . 0 findings`.
4541
+ *
4542
+ * The failure is written onto the record as it now stands rather than onto the snapshot, so the
4543
+ * last thing the agent said survives being failed. Only a driver whose own claim has gone is
4544
+ * refused, so a job nobody holds -- a crashed driver's, a resumed run's -- is still failable.
4109
4545
  */
4110
- function recordFailure(job, message, kind, detail) {
4111
- const current = loadJob(job.id);
4112
- if (current && current.updatedAt !== job.updatedAt) return current;
4113
- const failed = fail(job, message, kind, detail);
4546
+ function recordFailure(job, message, driver, kind, detail) {
4547
+ const current = loadJob(job.id) ?? job;
4548
+ const claim = liveClaim(job.id);
4549
+ if (claim && claim.holder !== driver) return current;
4550
+ const failed = fail(current, message, kind, detail);
4114
4551
  saveJob(failed);
4115
4552
  return failed;
4116
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
+ }
4117
4568
  function isTerminal(job) {
4118
4569
  return job.phase === "done" || job.phase === "failed";
4119
4570
  }
@@ -4638,302 +5089,85 @@ async function removeWorktree(input) {
4638
5089
  path
4639
5090
  ]);
4640
5091
  return {
4641
- removed: true,
4642
- how: "git"
4643
- };
4644
- } catch (error) {
4645
- return {
4646
- removed: false,
4647
- reason: "failed",
4648
- message: message$1(error)
4649
- };
4650
- }
4651
- const marker = readMarker(path);
4652
- if (!marker) return {
4653
- removed: false,
4654
- reason: "no-marker",
4655
- message: `${path} has no PR Review Buddy marker, so there is no evidence it was created here. It was left alone and needs removing by hand.`
4656
- };
4657
- if (realpathOrSelf(marker.originRepoPath) !== realpathOrSelf(input.originRepoPath)) return {
4658
- removed: false,
4659
- reason: "marker-mismatch",
4660
- message: `${path} is marked as belonging to ${marker.originRepoPath}, not ${input.originRepoPath}.`
4661
- };
4662
- if (now - marker.createdAt < minAge) return {
4663
- removed: false,
4664
- reason: "too-young",
4665
- message: `${path} is too recent to treat as residue.`
4666
- };
4667
- try {
4668
- rmSync(path, {
4669
- recursive: true,
4670
- force: true
4671
- });
4672
- await git(input.originRepoPath, ["worktree", "prune"]);
4673
- return {
4674
- removed: true,
4675
- how: "direct"
4676
- };
4677
- } catch (error) {
4678
- return {
4679
- removed: false,
4680
- reason: "failed",
4681
- message: message$1(error)
4682
- };
4683
- }
4684
- }
4685
- /** The resolved path if it is genuinely inside the managed root, else null. */
4686
- function withinManagedRoot(candidate) {
4687
- const root = realpathOrSelf(MANAGED_ROOT);
4688
- const path = realpathOrSelf(resolve(candidate));
4689
- if (path === root) return null;
4690
- return path.startsWith(root + sep) ? path : null;
4691
- }
4692
- /** Ask git, in the origin repository, whether this path is one of its worktrees. */
4693
- async function gitOwns(originRepoPath, path) {
4694
- let listing;
4695
- try {
4696
- listing = await git(originRepoPath, [
4697
- "worktree",
4698
- "list",
4699
- "--porcelain"
4700
- ]);
4701
- } catch {
4702
- return false;
4703
- }
4704
- return listing.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => realpathOrSelf(line.slice(9).trim())).includes(path);
4705
- }
4706
- /**
4707
- * macOS puts temporary directories behind `/private`, and git reports the resolved form while a
4708
- * caller may hold the symlinked one. Comparing them unresolved makes the ownership check fail on
4709
- * exactly the machines this is developed on.
4710
- */
4711
- function realpathOrSelf(path) {
4712
- try {
4713
- return realpathSync(path);
4714
- } catch {
4715
- return resolve(path);
4716
- }
4717
- }
4718
- function message$1(error) {
4719
- return error instanceof Error ? error.message : String(error);
4720
- }
4721
- //#endregion
4722
- //#region ../../packages/review-harness/src/workspace/exclusive_claim.ts
4723
- /**
4724
- * At most one holder of a name at a time, across processes, on this machine.
4725
- *
4726
- * Extracted from `job_claim.ts`, which had written all of this for one caller. A second caller then
4727
- * needed the same thing under a different name -- see `review_lock.ts` -- and the alternative was a
4728
- * second concurrency system beside a working one, which is how two mechanisms come to disagree
4729
- * about who holds what.
4730
- *
4731
- * The mechanism, and the reasoning behind each half of it, lives here. What a particular claim
4732
- * *means* stays with the module that takes it: "at most one driver owns a job" is a statement about
4733
- * jobs, not about `link()`.
4734
- *
4735
- * Never holds a credential, and holds nothing about the thing it names: a claim is a pid, a host,
4736
- * and two timestamps.
4737
- */
4738
- /**
4739
- * One directory for every kind of claim, keyed by name.
4740
- *
4741
- * Kept out of `jobs/`, where `allJobs` reads every `.json` and parses it as a `ReviewJob`: a claim
4742
- * file there would be swept as a job. Keys from different callers share the directory and are kept
4743
- * apart by shape -- a job claim is a UUID, a review lock is `review-<id>` -- so neither can name
4744
- * the other's file by accident.
4745
- */
4746
- var CLAIMS_DIR = join(STORE_ROOT, "claims");
4747
- /** Comfortably inside the lease, so one slow tick is never enough to lose a claim that is held. */
4748
- var CLAIM_RENEW_MS = 3e4;
4749
- function claimPath(key) {
4750
- return join(CLAIMS_DIR, `${key}.json`);
4751
- }
4752
- function readClaim(key) {
4753
- try {
4754
- return JSON.parse(readFileSync(claimPath(key), "utf8"));
4755
- } catch {
4756
- return null;
4757
- }
4758
- }
4759
- /**
4760
- * Whether the process behind a claim still exists.
4761
- *
4762
- * Signal 0 checks for a process without touching it: `ESRCH` means gone, and `EPERM` means it is
4763
- * there but owned by somebody else, which is still there. Only meaningful for a claim written on
4764
- * this machine, and `~/.prreviewbuddy` can sit on a synced home directory, so a claim from another
4765
- * host is left to the lease alone rather than judged against a pid that means nothing here.
4766
- */
4767
- function ownerAlive(claim) {
4768
- if (claim.host !== hostname()) return true;
4769
- try {
4770
- process.kill(claim.pid, 0);
4771
- return true;
4772
- } catch (error) {
4773
- return error.code === "EPERM";
4774
- }
4775
- }
4776
- /**
4777
- * The claim on this key, if one is genuinely held.
4778
- *
4779
- * Both conditions, because each covers the other's blind spot. A pid check recovers from a killed
4780
- * holder at once but can be fooled by a reused pid; a lease cannot be fooled but is always late by
4781
- * up to its own length. Requiring both means a reused pid with a stale lease reads as free, and a
4782
- * dead pid with a fresh lease reads as free immediately.
4783
- */
4784
- function liveClaim$1(key, now = Date.now()) {
4785
- const claim = readClaim(key);
4786
- if (!claim) return null;
4787
- if (now - claim.renewedAt >= 18e4) return null;
4788
- if (!ownerAlive(claim)) return null;
4789
- return claim;
4790
- }
4791
- /**
4792
- * Take the claim on a key, or return null because somebody else holds it.
4793
- *
4794
- * Synchronous from the check to the create, with no `await` between them, so two calls inside one
4795
- * process cannot both pass: this keeps the property a module-level `Set` had, and adds the one it
4796
- * lacked.
4797
- *
4798
- * Across processes the create is `link`, which fails with `EEXIST` if the name is taken and does so
4799
- * atomically, unlike a write with a prior existence check. A stale claim is unlinked first, and
4800
- * only if it is still the same stale claim that was read a line earlier, identified by `holder`.
4801
- * The residual race is two processes stealing the *same* stale claim within the same few
4802
- * microseconds, where the loser can delete the winner's fresh file. Left rather than solved: it
4803
- * needs a crashed holder and two attempts fired at the same instant, and `recordFailure` already
4804
- * refuses to write over a job record that moved underneath it, so the cost of losing that race is
4805
- * duplicated work rather than a corrupted review.
4806
- *
4807
- * **Never waits.** A caller that cannot have the key is told so immediately. Queueing would turn a
4808
- * deletion racing an analysis into a deletion that happens twenty minutes later, when the person
4809
- * who asked for it has stopped looking.
4810
- */
4811
- function acquire(key, now = Date.now()) {
4812
- const existing = readClaim(key);
4813
- if (existing) {
4814
- if (liveClaim$1(key, now)) return null;
4815
- const current = readClaim(key);
4816
- if (current && current.holder !== existing.holder) return null;
4817
- try {
4818
- unlinkSync(claimPath(key));
4819
- } catch {}
4820
- }
4821
- const claim = {
4822
- key,
4823
- holder: randomUUID(),
4824
- pid: process.pid,
4825
- host: hostname(),
4826
- acquiredAt: now,
4827
- renewedAt: now
4828
- };
4829
- mkdirSync(CLAIMS_DIR, { recursive: true });
4830
- const staging = join(CLAIMS_DIR, `.${claim.holder}.tmp`);
4831
- writeFileSync(staging, JSON.stringify(claim), { mode: 384 });
4832
- try {
4833
- linkSync(staging, claimPath(key));
4834
- } catch {
4835
- return null;
4836
- } finally {
4837
- rmSync(staging, { force: true });
4838
- }
4839
- return startHolding(claim);
4840
- }
4841
- /**
4842
- * Keep the claim fresh for as long as the work runs, and give it up afterwards.
4843
- *
4844
- * The timer is unreferenced on purpose. A heartbeat must never be the reason a process stays alive:
4845
- * the CLI is held open by the await on the job and the workspace server by its socket, and if
4846
- * neither is true any more then this claim is exactly the kind that should be allowed to expire.
4847
- *
4848
- * A renewal that finds the claim *gone* writes it back rather than giving up. A holder whose event
4849
- * loop was blocked past the lease -- parsing a very large diff, say -- can have its claim judged
4850
- * expired and swept while it is still perfectly alive, and a holder that then quietly stopped
4851
- * renewing would leave the key looking free for the rest of its run. Nobody holds the file, so
4852
- * taking it again is the true statement.
4853
- *
4854
- * A renewal that finds the claim held by somebody *else* stops rather than writing over it. By then
4855
- * the other holder is the real one, and reasserting ownership here would produce the two holders
4856
- * this whole file exists to prevent.
4857
- */
4858
- function startHolding(claim) {
4859
- let held = claim;
4860
- const timer = setInterval(() => {
4861
- const current = readClaim(held.key);
4862
- if (current && current.holder !== held.holder) {
4863
- clearInterval(timer);
4864
- return;
4865
- }
4866
- held = {
4867
- ...held,
4868
- renewedAt: Date.now()
4869
- };
4870
- try {
4871
- writeFileSync(claimPath(held.key), JSON.stringify(held), { mode: 384 });
4872
- } catch {}
4873
- }, CLAIM_RENEW_MS);
4874
- timer.unref?.();
4875
- return {
4876
- get claim() {
4877
- return held;
4878
- },
4879
- release() {
4880
- clearInterval(timer);
4881
- const current = readClaim(held.key);
4882
- if (current && current.holder !== held.holder) return;
4883
- try {
4884
- unlinkSync(claimPath(held.key));
4885
- } catch {}
4886
- }
5092
+ removed: true,
5093
+ how: "git"
5094
+ };
5095
+ } catch (error) {
5096
+ return {
5097
+ removed: false,
5098
+ reason: "failed",
5099
+ message: message$1(error)
5100
+ };
5101
+ }
5102
+ const marker = readMarker(path);
5103
+ if (!marker) return {
5104
+ removed: false,
5105
+ reason: "no-marker",
5106
+ message: `${path} has no PR Review Buddy marker, so there is no evidence it was created here. It was left alone and needs removing by hand.`
5107
+ };
5108
+ if (realpathOrSelf(marker.originRepoPath) !== realpathOrSelf(input.originRepoPath)) return {
5109
+ removed: false,
5110
+ reason: "marker-mismatch",
5111
+ message: `${path} is marked as belonging to ${marker.originRepoPath}, not ${input.originRepoPath}.`
5112
+ };
5113
+ if (now - marker.createdAt < minAge) return {
5114
+ removed: false,
5115
+ reason: "too-young",
5116
+ message: `${path} is too recent to treat as residue.`
4887
5117
  };
4888
- }
4889
- /** Drop a claim without holding it first. For a key being destroyed, and for tests. */
4890
- function clearClaim$1(key) {
4891
5118
  try {
4892
- unlinkSync(claimPath(key));
4893
- } catch {}
5119
+ rmSync(path, {
5120
+ recursive: true,
5121
+ force: true
5122
+ });
5123
+ await git(input.originRepoPath, ["worktree", "prune"]);
5124
+ return {
5125
+ removed: true,
5126
+ how: "direct"
5127
+ };
5128
+ } catch (error) {
5129
+ return {
5130
+ removed: false,
5131
+ reason: "failed",
5132
+ message: message$1(error)
5133
+ };
5134
+ }
4894
5135
  }
4895
- //#endregion
4896
- //#region ../../packages/review-harness/src/workspace/job_claim.ts
4897
- /**
4898
- * Who is driving a job right now, recorded where another process can see it.
4899
- *
4900
- * The invariant this exists to state: **at most one driver owns a job at a time.** `runJob` used to
4901
- * enforce that with a module-level `Set`, which was correct when one process called it and became
4902
- * silently wrong the moment three did -- the MCP server, the workspace server's Retry route, and
4903
- * the CLI. A guard that is invisible across processes is not a guard against the case that
4904
- * actually happens, which is two surfaces on one machine reaching the same job.
4905
- *
4906
- * Idempotence at each collision point was the alternative, and it was rejected: it would have to be
4907
- * rebuilt separately for `createWorktree`, for context building, for the agent spawn and for
4908
- * applying the result, and each one would only ever be as good as the last person who remembered.
4909
- * One claim covers the whole lifecycle.
4910
- *
4911
- * The mechanism is `exclusive_claim.ts`, and this file is now the naming: a job is claimed under
4912
- * its own id, so `claims/<jobId>.json` is where it has always been. The mechanism moved out when a
4913
- * review lock needed the same guarantee under a different name; what stays here is what a job claim
4914
- * means, which is the part that is about jobs rather than about `link()`.
4915
- */
4916
- /** The claim on this job, if a driver genuinely holds it. */
4917
- function liveClaim(jobId, now = Date.now()) {
4918
- return liveClaim$1(jobId, now);
5136
+ /** The resolved path if it is genuinely inside the managed root, else null. */
5137
+ function withinManagedRoot(candidate) {
5138
+ const root = realpathOrSelf(MANAGED_ROOT);
5139
+ const path = realpathOrSelf(resolve(candidate));
5140
+ if (path === root) return null;
5141
+ return path.startsWith(root + sep) ? path : null;
4919
5142
  }
4920
- /** Whether a driver owns this job right now. The question every other module actually asks. */
4921
- function isClaimed(jobId, now = Date.now()) {
4922
- return liveClaim(jobId, now) !== null;
5143
+ /** Ask git, in the origin repository, whether this path is one of its worktrees. */
5144
+ async function gitOwns(originRepoPath, path) {
5145
+ let listing;
5146
+ try {
5147
+ listing = await git(originRepoPath, [
5148
+ "worktree",
5149
+ "list",
5150
+ "--porcelain"
5151
+ ]);
5152
+ } catch {
5153
+ return false;
5154
+ }
5155
+ return listing.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => realpathOrSelf(line.slice(9).trim())).includes(path);
4923
5156
  }
4924
5157
  /**
4925
- * Take the claim on a job, or return null because another driver holds it.
4926
- *
4927
- * Two retries fired together, a retry racing the run `startJob` already kicked off, or a retry
4928
- * racing a driver the sweep wrongly called stopped: all of them land here rather than both reaching
4929
- * `createWorktree` or the analysis spawn.
5158
+ * macOS puts temporary directories behind `/private`, and git reports the resolved form while a
5159
+ * caller may hold the symlinked one. Comparing them unresolved makes the ownership check fail on
5160
+ * exactly the machines this is developed on.
4930
5161
  */
4931
- function claimJob(jobId, now = Date.now()) {
4932
- return acquire(jobId, now);
5162
+ function realpathOrSelf(path) {
5163
+ try {
5164
+ return realpathSync(path);
5165
+ } catch {
5166
+ return resolve(path);
5167
+ }
4933
5168
  }
4934
- /** Drop a job's claim without holding it first. For a job being deleted, and for tests. */
4935
- function clearClaim(jobId) {
4936
- clearClaim$1(jobId);
5169
+ function message$1(error) {
5170
+ return error instanceof Error ? error.message : String(error);
4937
5171
  }
4938
5172
  //#endregion
4939
5173
  //#region ../../packages/review-harness/src/workspace/review_lock.ts
@@ -6907,7 +7141,14 @@ var REVIEW_ATTENTIONS = [
6907
7141
  * reply then finds the block *inside* a string and hands back a snippet of Ruby as the review,
6908
7142
  * which fails to parse and loses the whole thing.
6909
7143
  *
6910
- * So a fence is only believed when what it wraps looks like the object or array being asked for.
7144
+ * So a fence is believed only when what it wraps is the object or array being asked for **and
7145
+ * parses whole**. Looking at the opening alone is not enough, because the marker that ends the
7146
+ * match need not be the one that ends the fence: a model that wraps its answer *and* puts a
7147
+ * fenced `bash` snippet in one of its own strings closes the non-greedy match 24KB early, in the
7148
+ * middle of a string, on a candidate that still begins with `{`. A live review answered in
7149
+ * exactly that shape and lost 26KB of perfectly good JSON. A fence says where the answer is, not
7150
+ * where it ends, so a candidate that does not parse is passed over rather than returned, and the
7151
+ * scanner below -- which tracks strings and reads such an object whole -- gets its turn.
6911
7152
  *
6912
7153
  * With no fence anywhere, the value is cut out of whatever surrounds it. The prompt asks for JSON
6913
7154
  * and nothing else, and that is the *only* instruction in the session this product controls: the
@@ -6923,10 +7164,19 @@ function extractJsonText(raw) {
6923
7164
  const text = raw.trim();
6924
7165
  for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
6925
7166
  const inner = match[1].trim();
6926
- if (inner.startsWith("{") || inner.startsWith("[")) return inner;
7167
+ if ((inner.startsWith("{") || inner.startsWith("[")) && parses(inner)) return inner;
6927
7168
  }
6928
7169
  return firstJsonValue(text) ?? text;
6929
7170
  }
7171
+ /** Whether this is a whole JSON value, which is the only thing that makes a candidate an answer. */
7172
+ function parses(candidate) {
7173
+ try {
7174
+ JSON.parse(candidate);
7175
+ return true;
7176
+ } catch {
7177
+ return false;
7178
+ }
7179
+ }
6930
7180
  /**
6931
7181
  * The first slice of this text that is a whole JSON value.
6932
7182
  *
@@ -8412,6 +8662,7 @@ async function startJob(input) {
8412
8662
  base: input.base,
8413
8663
  resolveRequest: input.resolveRequest
8414
8664
  }), input.base);
8665
+ await refuseEmptyTarget(target);
8415
8666
  const agent = input.agentId ? agentById(input.agentId) : defaultAgent();
8416
8667
  const review = input.review ?? { type: "standard" };
8417
8668
  const workspace = createWorkspace({
@@ -8427,6 +8678,7 @@ async function startJob(input) {
8427
8678
  workspaceId: workspace.id,
8428
8679
  target,
8429
8680
  env: agent.captureEnv(),
8681
+ purpose: "review",
8430
8682
  ...review.type === "kiss" ? {
8431
8683
  reviewType: "kiss",
8432
8684
  audience: review.audience
@@ -8458,13 +8710,13 @@ async function startJob(input) {
8458
8710
  * The previous result is snapshotted before the session is replaced, so the review keeps saying who
8459
8711
  * made the analysis it is retiring as well as who made the one it now shows.
8460
8712
  */
8461
- async function reanalyseReview(workspaceId, agentId) {
8713
+ async function reanalyseReview(workspaceId, agentId, onStarted) {
8462
8714
  const workspace = loadWorkspace(workspaceId);
8463
8715
  if (!workspace) throw new Error("No such review.");
8464
8716
  const sha = workspace.session.metadata?.commitSha;
8465
8717
  if (!sha) throw new Error("This review does not record which commit it describes, so it cannot be analysed again.");
8466
8718
  const agent = agentId ? agentById(agentId) : agentById(agentIdOf(workspace.session));
8467
- if (isBusy(await withReviewLock(workspaceId, () => {
8719
+ const started = await withReviewLock(workspaceId, () => {
8468
8720
  if (workspace.session.result) workspace.previousResult = {
8469
8721
  result: workspace.session.result,
8470
8722
  commitSha: sha,
@@ -8481,13 +8733,17 @@ async function reanalyseReview(workspaceId, agentId) {
8481
8733
  const job = createJob({
8482
8734
  workspaceId: workspace.id,
8483
8735
  target,
8484
- env: agent.captureEnv()
8736
+ env: agent.captureEnv(),
8737
+ purpose: "review"
8485
8738
  });
8486
8739
  saveJob(job);
8487
8740
  workspace.jobId = job.id;
8488
8741
  workspace.session = beginReviewSession(sha, agent);
8489
8742
  saveWorkspace(workspace);
8490
- }))) throw new ReviewBeingDeletedError();
8743
+ return job;
8744
+ });
8745
+ if (isBusy(started)) throw new ReviewBeingDeletedError();
8746
+ onStarted?.(started.id);
8491
8747
  return runJob(workspaceId);
8492
8748
  }
8493
8749
  /**
@@ -8528,7 +8784,7 @@ async function runJob(workspaceId, runners = {}, reviewType = "standard") {
8528
8784
  });
8529
8785
  if (isBusy(started)) throw busyReason(workspaceId, reviewType);
8530
8786
  try {
8531
- return await drive(started.jobId, workspaceId, runners);
8787
+ return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder);
8532
8788
  } finally {
8533
8789
  started.claim.release();
8534
8790
  }
@@ -8581,7 +8837,7 @@ function runOfJob(workspace, jobId) {
8581
8837
  if (!run) throw new Error("This KISS job has no run to write its answer to.");
8582
8838
  return run;
8583
8839
  }
8584
- async function drive(jobId, workspaceId, runners) {
8840
+ async function drive(jobId, workspaceId, runners, driver) {
8585
8841
  let job = loadJob(jobId);
8586
8842
  if (!job) throw new Error("No such job.");
8587
8843
  const startedAt = Date.now();
@@ -8688,7 +8944,7 @@ async function drive(jobId, workspaceId, runners) {
8688
8944
  } catch (error) {
8689
8945
  const kind = error instanceof GuideRejected || error instanceof KissRejected ? "agent_output_invalid" : error instanceof AgentRunError ? error.kind : void 0;
8690
8946
  const raw = message(error);
8691
- job = recordFailure(job, explain(job, raw), kind, raw);
8947
+ job = recordFailure(job, explain(job, raw), driver, kind, raw);
8692
8948
  if (job.reviewType === "kiss") failCurrentKissRun(workspaceId, job, job.failure?.message ?? raw, kind, error instanceof KissRejected ? error.answer : void 0);
8693
8949
  finished(job, workspaceId, startedAt, false);
8694
8950
  return job;
@@ -8957,6 +9213,7 @@ async function recordKissRun(input) {
8957
9213
  workspaceId: workspace.id,
8958
9214
  target,
8959
9215
  env: agent.captureEnv(),
9216
+ purpose: "review",
8960
9217
  reviewType: "kiss",
8961
9218
  audience: input.audience
8962
9219
  });
@@ -9350,6 +9607,56 @@ function countByStatus(issues) {
9350
9607
  return counts;
9351
9608
  }
9352
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
9353
9660
  //#region ../../packages/review-harness/src/workspace/pinned_checkout.ts
9354
9661
  /**
9355
9662
  * A checkout of one commit, for anything that needs to read the reviewed code.
@@ -9382,8 +9689,62 @@ var NoCheckoutError = class extends Error {};
9382
9689
  * look. Claimed inside the lock too, so there is no instant where a job record for this review
9383
9690
  * exists unclaimed and a deletion between the two would see nothing running.
9384
9691
  */
9385
- 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) {
9386
9746
  const started = await withReviewLock(workspace.id, () => {
9747
+ if (fields.purpose === "update" && liveUpdateFor(workspace.id)) throw new UpdateAlreadyRunningError();
9387
9748
  const target = {
9388
9749
  originRepoPath: workspace.repoPath,
9389
9750
  branch: workspace.changeSet.headRef,
@@ -9393,7 +9754,8 @@ async function pinCheckout(workspace, sha) {
9393
9754
  const job = createJob({
9394
9755
  workspaceId: workspace.id,
9395
9756
  target,
9396
- env: captureEnv()
9757
+ env: captureEnv(),
9758
+ ...fields
9397
9759
  });
9398
9760
  saveJob(job);
9399
9761
  const claim = claimJob(job.id);
@@ -9404,29 +9766,7 @@ async function pinCheckout(workspace, sha) {
9404
9766
  };
9405
9767
  });
9406
9768
  if (isBusy(started)) throw new ReviewBeingDeletedError();
9407
- let { job } = started;
9408
- const { claim } = started;
9409
- try {
9410
- const path = await createWorktree({
9411
- originRepoPath: workspace.repoPath,
9412
- jobId: job.id,
9413
- sha
9414
- });
9415
- job = {
9416
- ...job,
9417
- worktreePath: path
9418
- };
9419
- saveJob(job);
9420
- return {
9421
- path,
9422
- job,
9423
- claim
9424
- };
9425
- } catch (error) {
9426
- saveJob(fail(job, error instanceof Error ? error.message : String(error)));
9427
- claim.release();
9428
- throw error;
9429
- }
9769
+ return started;
9430
9770
  }
9431
9771
  /**
9432
9772
  * Which checkout each open review's questions are being answered against.
@@ -9695,27 +10035,45 @@ var FETCH_TIMEOUT_MS = 6e4;
9695
10035
  */
9696
10036
  async function updateReview(workspace, runners = {}) {
9697
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) {
9698
10060
  const remote = await remoteHead(workspace);
9699
10061
  if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, noRemoteHeadMessage(workspace));
10062
+ if (workspace.prContext) step("conversation");
9700
10063
  const conversationOk = await reReadConversation(workspace, runners.runRefresh);
9701
10064
  if (reviewedSha && !await commitPresent(workspace.repoPath, reviewedSha)) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
9702
10065
  if (!reviewedSha || reviewedSha === remote.sha) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
10066
+ step("context");
9703
10067
  let worktreePath;
9704
- let job;
9705
- let claim;
9706
10068
  try {
9707
- ({path: worktreePath, job, claim} = await pinCheckout(workspace, remote.sha));
10069
+ ({path: worktreePath} = await pinCheckout(workspace, remote.sha, held));
9708
10070
  } catch (error) {
9709
10071
  if (error instanceof ReviewBeingDeletedError) throw error;
9710
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.");
9711
10073
  }
9712
- try {
9713
- return await reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath);
9714
- } finally {
9715
- saveJob(advance(job, "done"));
9716
- }
10074
+ return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id);
9717
10075
  }
9718
- async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath) {
10076
+ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId) {
9719
10077
  const newHead = remote.sha;
9720
10078
  let delta;
9721
10079
  try {
@@ -9729,10 +10087,19 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
9729
10087
  }
9730
10088
  const previous = workspace.session.result;
9731
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
+ };
9732
10099
  let raw;
9733
10100
  try {
9734
10101
  const prompt = buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath);
9735
- 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);
9736
10103
  } catch (error) {
9737
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.");
9738
10105
  }
@@ -10056,7 +10423,7 @@ ${buildReconciliationInstructions({
10056
10423
  * that captured the daemon's environment instead was reconciling one review's findings with
10057
10424
  * whichever agent and account the workspace server happened to be started under.
10058
10425
  */
10059
- async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env) {
10426
+ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgress) {
10060
10427
  try {
10061
10428
  return await withUsageRecorded(workspaceId, "update", env, (onUsage) => agentFor(env).run({
10062
10429
  capability: "read-code",
@@ -10065,6 +10432,7 @@ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env) {
10065
10432
  originRepoPath,
10066
10433
  env,
10067
10434
  timeoutMs: UPDATE_TIMEOUT_MS,
10435
+ onProgress,
10068
10436
  onUsage
10069
10437
  }));
10070
10438
  } catch (error) {
@@ -10542,32 +10910,6 @@ async function discardJob(job, options = {}) {
10542
10910
  return { removed: true };
10543
10911
  }
10544
10912
  //#endregion
10545
- //#region ../../packages/review-harness/src/workspace/running_jobs.ts
10546
- /**
10547
- * What is being made right now, as opposed to what a record says.
10548
- *
10549
- * Its own module because two commands need the same answer and neither should have to import the
10550
- * other to get it: `uninstall` refuses while anything is running, and deleting a review refuses
10551
- * while that review is. Splitting it out is the same move `store_root.ts` made out of `store.ts`.
10552
- */
10553
- /**
10554
- * Reviews being made at this moment.
10555
- *
10556
- * `liveClaim` rather than the job phase alone, and the distinction matters more here than anywhere:
10557
- * a job whose driver was killed sits in a working phase for up to half an hour before the sweep
10558
- * calls it stopped, and refusing over one of those would be refusing on behalf of a review that
10559
- * nothing is making. The claim answers about a process rather than about a record.
10560
- */
10561
- function runningJobs(now = Date.now()) {
10562
- return allJobs().filter((job) => !isTerminal(job)).flatMap((job) => {
10563
- const claim = liveClaim(job.id, now);
10564
- return claim ? [{
10565
- job,
10566
- claim
10567
- }] : [];
10568
- });
10569
- }
10570
- //#endregion
10571
10913
  //#region ../../packages/review-harness/src/workspace/delete_review.ts
10572
10914
  /**
10573
10915
  * Removing one review, or everything on one card.
@@ -10735,4 +11077,4 @@ function relativeTime(ms) {
10735
11077
  return `${days} day${days === 1 ? "" : "s"} ago`;
10736
11078
  }
10737
11079
  //#endregion
10738
- export { checkCodeFreshness 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, clearClaim 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, removeWorktree 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, MANAGED_ROOT as X, isClaimed as Y, readMarker as Z, writeServerRecord as _, recentReviewGroups as _t, liveJobsFor as a, saveJob as at, MIN_WORKSPACE_PORT as b, reviewedRepositories as bt, discardJob as c, followedRefName as ct, clearServerRecord as d, lineageKeyFor as dt, checkFreshness as et, ensureServer as f, lineagePosition as ft, workspaceUrl as g, previousKissResult as gt, stopServer as h, positionInLineage as ht, lineageIds as i, loadJob 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, fail as nt, wasBlocked as o, PHASES as ot, readServerRecord as p, loadWorkspace as pt, ReviewBeingDeletedError as q, AgentUnavailableError as qt, deleteReview as r, isTerminal as rt, runningJobs as s, progressSteps as st, relativeTime as t, allJobs 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 };