prreviewbuddy 0.25.7 → 0.25.8

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.8
4
+
5
+ **A review that fails says so, instead of reporting itself as ready.** An analysis that failed
6
+ after the agent had started work left no trace of the failure: the job stayed at `Analysing the
7
+ change`, the workspace page kept the running rail over it, and the terminal printed
8
+ `Review ready . Risk not rated . 0 findings`, which reads as a change with nothing wrong with it.
9
+ The review it was reporting did not exist.
10
+
11
+ The record refuses to be failed by anyone but the run that owns it, so that a driver whose job has
12
+ been taken over cannot mark a healthy review as broken. It asked that question of the record's
13
+ timestamp, and the timestamp moves every time the agent reports what it is doing. So a run that
14
+ streamed a single line and then failed looked, to itself, like a run that had been taken over, and
15
+ declined to record its own failure. It asks the claim now, which is who is actually driving.
16
+
17
+ **The terminal reports what happened, and refuses to invent the rest.** `prreviewbuddy review` read
18
+ "the job did not fail" as "the review is ready". A run can also stop without recording why, and now
19
+ says so, naming the phase it stopped in, which is the phase running the command again picks up
20
+ from. A finished job holding no review is reported as a review that stopped rather than as a clean
21
+ one: the standard review is held to the rule the KISS lens has always been held to.
22
+
23
+ **A fenced answer is read to its end, not to the first ``` inside it.** The model is asked for
24
+ JSON and often wraps it in a code fence, and the summary and next steps of a review about code
25
+ frequently hold a fenced snippet of their own. The unwrapping stopped at the first closing marker
26
+ it found, which in that case is the one *opening* a snippet inside one of the answer's own
27
+ strings. A real review of a ten-file pull request was cut in half 24KB in, mid-string, and thrown
28
+ away whole: four themes, six findings, six questions and six next steps, none of which anything
29
+ ever showed. A fence says where an answer starts, not where it ends, so one that does not parse is
30
+ now passed over for the scanner that reads such an object correctly.
31
+
3
32
  ## 0.25.7
4
33
 
5
34
  **The agent you chose is the agent that runs.** Setting a default agent, with
package/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { B as BUILD_VERSION, Bt as STORE_ROOT, C as statedWorkspacePort, E as updateReview, F as startJob, Ft as agentIdOf, G as forgeResolver, Gt as detectAgents, Ht as DEFAULT_AGENT_ID, J as clearClaim, L as record, Lt as readAgentPreference, Mt as resolveTarget, N as reanalyseReview, Nt as displayRef, P as runJob, Pt as git, Q as removeWorktree, R as recordTelemetryUploadConsent, Rt as writeAgentPreference, St as summarise, Ut as agentById, V as PACKAGE_NAME, Vt as AGENT_IDS, X as MANAGED_ROOT, Y as isClaimed, Z as readMarker, a as liveJobsFor, b as MIN_WORKSPACE_PORT, bt as reviewedRepositories, ct as followedRefName, dt as lineageKeyFor, et as checkFreshness, f as ensureServer, g as workspaceUrl, h as stopServer, ht as positionInLineage, it as loadJob, j as recordKissRun, l as readIndexToken, lt as groupByLineage, m as reviewsUrl, mt as matchingWorkspaceIds, o as wasBlocked, pt as loadWorkspace, r as deleteReview$1, rt as isTerminal, s as runningJobs, st as progressSteps, t as relativeTime, tt as allJobs, u as bootstrapUrl, v as DEFAULT_WORKSPACE_PORT, vt as recentWorkspaces, w as writePortPreference, x as parseWorkspacePort, xt as saveWorkspace, y as MAX_WORKSPACE_PORT, z as telemetryUploadConsent, zt as CONFIG_PATH } from "./relative_time-CDvK_Zaj.js";
2
+ import { $ as allJobs, B as BUILD_VERSION, Bt as STORE_ROOT, C as statedWorkspacePort, E as updateReview, F as startJob, Ft as agentIdOf, G as forgeResolver, Gt as detectAgents, Ht as DEFAULT_AGENT_ID, J as MANAGED_ROOT, L as record, Lt as readAgentPreference, Mt as resolveTarget, N as reanalyseReview, Nt as displayRef, P as runJob, Pt as git, Q as checkFreshness, R as recordTelemetryUploadConsent, Rt as writeAgentPreference, St as summarise, Ut as agentById, V as PACKAGE_NAME, Vt as AGENT_IDS, X as removeWorktree, Y as readMarker, a as liveJobsFor, at as progressSteps, b as MIN_WORKSPACE_PORT, bt as reviewedRepositories, ct as followedRefName, dt as lineageKeyFor, f as ensureServer, g as workspaceUrl, h as stopServer, ht as positionInLineage, it as PHASES, j as recordKissRun, l as readIndexToken, lt as groupByLineage, m as reviewsUrl, mt as matchingWorkspaceIds, nt as loadJob, o as wasBlocked, ot as clearClaim, pt as loadWorkspace, r as deleteReview$1, s as runningJobs, st as isClaimed, t as relativeTime, tt as isTerminal, u as bootstrapUrl, v as DEFAULT_WORKSPACE_PORT, vt as recentWorkspaces, w as writePortPreference, x as parseWorkspacePort, xt as saveWorkspace, y as MAX_WORKSPACE_PORT, z as telemetryUploadConsent, zt as CONFIG_PATH } from "./relative_time-D760FOL9.js";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -1950,9 +1950,9 @@ async function drive(workspaceId, url, kissRun) {
1950
1950
  let finished;
1951
1951
  try {
1952
1952
  finished = await runJob(workspaceId, {}, kissRun ? "kiss" : "standard");
1953
- if (finished.phase === "failed") {
1953
+ if (finished.phase !== "done") {
1954
1954
  stop();
1955
- err(reviewFailed(finished.failure?.message ?? "The reason was not recorded.", await linkNow(workspace, url)));
1955
+ err(reviewFailed(failureMessage(finished), await linkNow(workspace, url)));
1956
1956
  return 1;
1957
1957
  }
1958
1958
  } catch (error) {
@@ -1973,10 +1973,26 @@ async function drive(workspaceId, url, kissRun) {
1973
1973
  out(kissFinished(link, answer.recommendations.length));
1974
1974
  return 0;
1975
1975
  }
1976
+ if (!done.session.result) {
1977
+ err(reviewFailed("The analysis finished without recording a review.", link));
1978
+ return 1;
1979
+ }
1976
1980
  out(reviewFinished(link, summarise(done), finished.conversation));
1977
1981
  return 0;
1978
1982
  }
1979
1983
  /**
1984
+ * Why a run ended, when it did not end by finishing.
1985
+ *
1986
+ * A job that recorded a failure says so in its own words. One that did not is a driver that went
1987
+ * away mid-phase -- killed, or crashed past the point where it could write -- and the honest thing
1988
+ * to say is which phase it was in, because that is the phase the next run picks up from.
1989
+ */
1990
+ function failureMessage(job) {
1991
+ if (job.failure?.message) return job.failure.message;
1992
+ const doing = PHASES[job.phase].doing;
1993
+ return `The reason was not recorded. It was ${doing.charAt(0).toLowerCase()}${doing.slice(1)} when it stopped.`;
1994
+ }
1995
+ /**
1980
1996
  * Every link this command prints, and the reason they are not plain workspace URLs.
1981
1997
  *
1982
1998
  * A reviewer who only ever runs this command was never sent to the reviews index, so their browser
@@ -3869,6 +3869,223 @@ function pruneOldWorkspaces() {
3869
3869
  } catch {}
3870
3870
  }
3871
3871
  //#endregion
3872
+ //#region ../../packages/review-harness/src/workspace/exclusive_claim.ts
3873
+ /**
3874
+ * At most one holder of a name at a time, across processes, on this machine.
3875
+ *
3876
+ * Extracted from `job_claim.ts`, which had written all of this for one caller. A second caller then
3877
+ * needed the same thing under a different name -- see `review_lock.ts` -- and the alternative was a
3878
+ * second concurrency system beside a working one, which is how two mechanisms come to disagree
3879
+ * about who holds what.
3880
+ *
3881
+ * The mechanism, and the reasoning behind each half of it, lives here. What a particular claim
3882
+ * *means* stays with the module that takes it: "at most one driver owns a job" is a statement about
3883
+ * jobs, not about `link()`.
3884
+ *
3885
+ * Never holds a credential, and holds nothing about the thing it names: a claim is a pid, a host,
3886
+ * and two timestamps.
3887
+ */
3888
+ /**
3889
+ * One directory for every kind of claim, keyed by name.
3890
+ *
3891
+ * Kept out of `jobs/`, where `allJobs` reads every `.json` and parses it as a `ReviewJob`: a claim
3892
+ * file there would be swept as a job. Keys from different callers share the directory and are kept
3893
+ * apart by shape -- a job claim is a UUID, a review lock is `review-<id>` -- so neither can name
3894
+ * the other's file by accident.
3895
+ */
3896
+ var CLAIMS_DIR = join(STORE_ROOT, "claims");
3897
+ /** Comfortably inside the lease, so one slow tick is never enough to lose a claim that is held. */
3898
+ var CLAIM_RENEW_MS = 3e4;
3899
+ function claimPath(key) {
3900
+ return join(CLAIMS_DIR, `${key}.json`);
3901
+ }
3902
+ function readClaim(key) {
3903
+ try {
3904
+ return JSON.parse(readFileSync(claimPath(key), "utf8"));
3905
+ } catch {
3906
+ return null;
3907
+ }
3908
+ }
3909
+ /**
3910
+ * Whether the process behind a claim still exists.
3911
+ *
3912
+ * Signal 0 checks for a process without touching it: `ESRCH` means gone, and `EPERM` means it is
3913
+ * there but owned by somebody else, which is still there. Only meaningful for a claim written on
3914
+ * this machine, and `~/.prreviewbuddy` can sit on a synced home directory, so a claim from another
3915
+ * host is left to the lease alone rather than judged against a pid that means nothing here.
3916
+ */
3917
+ function ownerAlive(claim) {
3918
+ if (claim.host !== hostname()) return true;
3919
+ try {
3920
+ process.kill(claim.pid, 0);
3921
+ return true;
3922
+ } catch (error) {
3923
+ return error.code === "EPERM";
3924
+ }
3925
+ }
3926
+ /**
3927
+ * The claim on this key, if one is genuinely held.
3928
+ *
3929
+ * Both conditions, because each covers the other's blind spot. A pid check recovers from a killed
3930
+ * holder at once but can be fooled by a reused pid; a lease cannot be fooled but is always late by
3931
+ * up to its own length. Requiring both means a reused pid with a stale lease reads as free, and a
3932
+ * dead pid with a fresh lease reads as free immediately.
3933
+ */
3934
+ function liveClaim$1(key, now = Date.now()) {
3935
+ const claim = readClaim(key);
3936
+ if (!claim) return null;
3937
+ if (now - claim.renewedAt >= 18e4) return null;
3938
+ if (!ownerAlive(claim)) return null;
3939
+ return claim;
3940
+ }
3941
+ /**
3942
+ * Take the claim on a key, or return null because somebody else holds it.
3943
+ *
3944
+ * Synchronous from the check to the create, with no `await` between them, so two calls inside one
3945
+ * process cannot both pass: this keeps the property a module-level `Set` had, and adds the one it
3946
+ * lacked.
3947
+ *
3948
+ * Across processes the create is `link`, which fails with `EEXIST` if the name is taken and does so
3949
+ * atomically, unlike a write with a prior existence check. A stale claim is unlinked first, and
3950
+ * only if it is still the same stale claim that was read a line earlier, identified by `holder`.
3951
+ * The residual race is two processes stealing the *same* stale claim within the same few
3952
+ * microseconds, where the loser can delete the winner's fresh file. Left rather than solved: it
3953
+ * needs a crashed holder and two attempts fired at the same instant, and `recordFailure` already
3954
+ * refuses to write over a job record that moved underneath it, so the cost of losing that race is
3955
+ * duplicated work rather than a corrupted review.
3956
+ *
3957
+ * **Never waits.** A caller that cannot have the key is told so immediately. Queueing would turn a
3958
+ * deletion racing an analysis into a deletion that happens twenty minutes later, when the person
3959
+ * who asked for it has stopped looking.
3960
+ */
3961
+ function acquire(key, now = Date.now()) {
3962
+ const existing = readClaim(key);
3963
+ if (existing) {
3964
+ if (liveClaim$1(key, now)) return null;
3965
+ const current = readClaim(key);
3966
+ if (current && current.holder !== existing.holder) return null;
3967
+ try {
3968
+ unlinkSync(claimPath(key));
3969
+ } catch {}
3970
+ }
3971
+ const claim = {
3972
+ key,
3973
+ holder: randomUUID(),
3974
+ pid: process.pid,
3975
+ host: hostname(),
3976
+ acquiredAt: now,
3977
+ renewedAt: now
3978
+ };
3979
+ mkdirSync(CLAIMS_DIR, { recursive: true });
3980
+ const staging = join(CLAIMS_DIR, `.${claim.holder}.tmp`);
3981
+ writeFileSync(staging, JSON.stringify(claim), { mode: 384 });
3982
+ try {
3983
+ linkSync(staging, claimPath(key));
3984
+ } catch {
3985
+ return null;
3986
+ } finally {
3987
+ rmSync(staging, { force: true });
3988
+ }
3989
+ return startHolding(claim);
3990
+ }
3991
+ /**
3992
+ * Keep the claim fresh for as long as the work runs, and give it up afterwards.
3993
+ *
3994
+ * The timer is unreferenced on purpose. A heartbeat must never be the reason a process stays alive:
3995
+ * the CLI is held open by the await on the job and the workspace server by its socket, and if
3996
+ * neither is true any more then this claim is exactly the kind that should be allowed to expire.
3997
+ *
3998
+ * A renewal that finds the claim *gone* writes it back rather than giving up. A holder whose event
3999
+ * loop was blocked past the lease -- parsing a very large diff, say -- can have its claim judged
4000
+ * expired and swept while it is still perfectly alive, and a holder that then quietly stopped
4001
+ * renewing would leave the key looking free for the rest of its run. Nobody holds the file, so
4002
+ * taking it again is the true statement.
4003
+ *
4004
+ * A renewal that finds the claim held by somebody *else* stops rather than writing over it. By then
4005
+ * the other holder is the real one, and reasserting ownership here would produce the two holders
4006
+ * this whole file exists to prevent.
4007
+ */
4008
+ function startHolding(claim) {
4009
+ let held = claim;
4010
+ const timer = setInterval(() => {
4011
+ const current = readClaim(held.key);
4012
+ if (current && current.holder !== held.holder) {
4013
+ clearInterval(timer);
4014
+ return;
4015
+ }
4016
+ held = {
4017
+ ...held,
4018
+ renewedAt: Date.now()
4019
+ };
4020
+ try {
4021
+ writeFileSync(claimPath(held.key), JSON.stringify(held), { mode: 384 });
4022
+ } catch {}
4023
+ }, CLAIM_RENEW_MS);
4024
+ timer.unref?.();
4025
+ return {
4026
+ get claim() {
4027
+ return held;
4028
+ },
4029
+ release() {
4030
+ clearInterval(timer);
4031
+ const current = readClaim(held.key);
4032
+ if (current && current.holder !== held.holder) return;
4033
+ try {
4034
+ unlinkSync(claimPath(held.key));
4035
+ } catch {}
4036
+ }
4037
+ };
4038
+ }
4039
+ /** Drop a claim without holding it first. For a key being destroyed, and for tests. */
4040
+ function clearClaim$1(key) {
4041
+ try {
4042
+ unlinkSync(claimPath(key));
4043
+ } catch {}
4044
+ }
4045
+ //#endregion
4046
+ //#region ../../packages/review-harness/src/workspace/job_claim.ts
4047
+ /**
4048
+ * Who is driving a job right now, recorded where another process can see it.
4049
+ *
4050
+ * The invariant this exists to state: **at most one driver owns a job at a time.** `runJob` used to
4051
+ * enforce that with a module-level `Set`, which was correct when one process called it and became
4052
+ * silently wrong the moment three did -- the MCP server, the workspace server's Retry route, and
4053
+ * the CLI. A guard that is invisible across processes is not a guard against the case that
4054
+ * actually happens, which is two surfaces on one machine reaching the same job.
4055
+ *
4056
+ * Idempotence at each collision point was the alternative, and it was rejected: it would have to be
4057
+ * rebuilt separately for `createWorktree`, for context building, for the agent spawn and for
4058
+ * applying the result, and each one would only ever be as good as the last person who remembered.
4059
+ * One claim covers the whole lifecycle.
4060
+ *
4061
+ * The mechanism is `exclusive_claim.ts`, and this file is now the naming: a job is claimed under
4062
+ * its own id, so `claims/<jobId>.json` is where it has always been. The mechanism moved out when a
4063
+ * review lock needed the same guarantee under a different name; what stays here is what a job claim
4064
+ * means, which is the part that is about jobs rather than about `link()`.
4065
+ */
4066
+ /** The claim on this job, if a driver genuinely holds it. */
4067
+ function liveClaim(jobId, now = Date.now()) {
4068
+ return liveClaim$1(jobId, now);
4069
+ }
4070
+ /** Whether a driver owns this job right now. The question every other module actually asks. */
4071
+ function isClaimed(jobId, now = Date.now()) {
4072
+ return liveClaim(jobId, now) !== null;
4073
+ }
4074
+ /**
4075
+ * Take the claim on a job, or return null because another driver holds it.
4076
+ *
4077
+ * Two retries fired together, a retry racing the run `startJob` already kicked off, or a retry
4078
+ * racing a driver the sweep wrongly called stopped: all of them land here rather than both reaching
4079
+ * `createWorktree` or the analysis spawn.
4080
+ */
4081
+ function claimJob(jobId, now = Date.now()) {
4082
+ return acquire(jobId, now);
4083
+ }
4084
+ /** Drop a job's claim without holding it first. For a job being deleted, and for tests. */
4085
+ function clearClaim(jobId) {
4086
+ clearClaim$1(jobId);
4087
+ }
4088
+ //#endregion
3872
4089
  //#region ../../packages/review-harness/src/workspace/job_progress.ts
3873
4090
  /**
3874
4091
  * The phases that do work, in the order they run. `done` and `failed` are outcomes, not work.
@@ -4097,20 +4314,31 @@ function fail(job, message, kind, detail) {
4097
4314
  };
4098
4315
  }
4099
4316
  /**
4100
- * Record a failure, without overwriting a record that has moved on since this run last saw it.
4317
+ * Record a failure, unless somebody else is driving this job now.
4318
+ *
4319
+ * A run only reaches this after an `await` on the git call or the agent spawn that just rejected,
4320
+ * and whatever this run's own `job` variable holds by then is a snapshot from before that. If the
4321
+ * run that owns the job is no longer this one -- this one's claim lapsed and another driver picked
4322
+ * the job up -- saving this run's failure would take a job that is running fine and record it as
4323
+ * failed, and nothing downstream could tell the difference from a real one: `saveJob` writes
4324
+ * whatever it is given.
4101
4325
  *
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.
4326
+ * The question is asked of the claim, which is who is driving, and not of `updatedAt`, which is
4327
+ * only when the record last moved. Those two came apart the moment the analysis began streaming
4328
+ * progress: `runAnalysisFor` writes every line the agent reports onto the record with a fresh
4329
+ * `updatedAt` and does not hand the new copy back, so a driver's own commentary made its own
4330
+ * snapshot look stale and it declined to record its own failure. The job stayed at `analysing` for
4331
+ * ever, the page kept the running rail over it, and the CLI printed `Review ready . 0 findings`.
4332
+ *
4333
+ * The failure is written onto the record as it now stands rather than onto the snapshot, so the
4334
+ * last thing the agent said survives being failed. Only a driver whose own claim has gone is
4335
+ * refused, so a job nobody holds -- a crashed driver's, a resumed run's -- is still failable.
4109
4336
  */
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);
4337
+ function recordFailure(job, message, driver, kind, detail) {
4338
+ const current = loadJob(job.id) ?? job;
4339
+ const claim = liveClaim(job.id);
4340
+ if (claim && claim.holder !== driver) return current;
4341
+ const failed = fail(current, message, kind, detail);
4114
4342
  saveJob(failed);
4115
4343
  return failed;
4116
4344
  }
@@ -4719,223 +4947,6 @@ function message$1(error) {
4719
4947
  return error instanceof Error ? error.message : String(error);
4720
4948
  }
4721
4949
  //#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
- }
4887
- };
4888
- }
4889
- /** Drop a claim without holding it first. For a key being destroyed, and for tests. */
4890
- function clearClaim$1(key) {
4891
- try {
4892
- unlinkSync(claimPath(key));
4893
- } catch {}
4894
- }
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);
4919
- }
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;
4923
- }
4924
- /**
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.
4930
- */
4931
- function claimJob(jobId, now = Date.now()) {
4932
- return acquire(jobId, now);
4933
- }
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);
4937
- }
4938
- //#endregion
4939
4950
  //#region ../../packages/review-harness/src/workspace/review_lock.ts
4940
4951
  /**
4941
4952
  * The lock that makes "nothing is running" a fact rather than an observation.
@@ -6907,7 +6918,14 @@ var REVIEW_ATTENTIONS = [
6907
6918
  * reply then finds the block *inside* a string and hands back a snippet of Ruby as the review,
6908
6919
  * which fails to parse and loses the whole thing.
6909
6920
  *
6910
- * So a fence is only believed when what it wraps looks like the object or array being asked for.
6921
+ * So a fence is believed only when what it wraps is the object or array being asked for **and
6922
+ * parses whole**. Looking at the opening alone is not enough, because the marker that ends the
6923
+ * match need not be the one that ends the fence: a model that wraps its answer *and* puts a
6924
+ * fenced `bash` snippet in one of its own strings closes the non-greedy match 24KB early, in the
6925
+ * middle of a string, on a candidate that still begins with `{`. A live review answered in
6926
+ * exactly that shape and lost 26KB of perfectly good JSON. A fence says where the answer is, not
6927
+ * where it ends, so a candidate that does not parse is passed over rather than returned, and the
6928
+ * scanner below -- which tracks strings and reads such an object whole -- gets its turn.
6911
6929
  *
6912
6930
  * With no fence anywhere, the value is cut out of whatever surrounds it. The prompt asks for JSON
6913
6931
  * and nothing else, and that is the *only* instruction in the session this product controls: the
@@ -6923,10 +6941,19 @@ function extractJsonText(raw) {
6923
6941
  const text = raw.trim();
6924
6942
  for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
6925
6943
  const inner = match[1].trim();
6926
- if (inner.startsWith("{") || inner.startsWith("[")) return inner;
6944
+ if ((inner.startsWith("{") || inner.startsWith("[")) && parses(inner)) return inner;
6927
6945
  }
6928
6946
  return firstJsonValue(text) ?? text;
6929
6947
  }
6948
+ /** Whether this is a whole JSON value, which is the only thing that makes a candidate an answer. */
6949
+ function parses(candidate) {
6950
+ try {
6951
+ JSON.parse(candidate);
6952
+ return true;
6953
+ } catch {
6954
+ return false;
6955
+ }
6956
+ }
6930
6957
  /**
6931
6958
  * The first slice of this text that is a whole JSON value.
6932
6959
  *
@@ -8528,7 +8555,7 @@ async function runJob(workspaceId, runners = {}, reviewType = "standard") {
8528
8555
  });
8529
8556
  if (isBusy(started)) throw busyReason(workspaceId, reviewType);
8530
8557
  try {
8531
- return await drive(started.jobId, workspaceId, runners);
8558
+ return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder);
8532
8559
  } finally {
8533
8560
  started.claim.release();
8534
8561
  }
@@ -8581,7 +8608,7 @@ function runOfJob(workspace, jobId) {
8581
8608
  if (!run) throw new Error("This KISS job has no run to write its answer to.");
8582
8609
  return run;
8583
8610
  }
8584
- async function drive(jobId, workspaceId, runners) {
8611
+ async function drive(jobId, workspaceId, runners, driver) {
8585
8612
  let job = loadJob(jobId);
8586
8613
  if (!job) throw new Error("No such job.");
8587
8614
  const startedAt = Date.now();
@@ -8688,7 +8715,7 @@ async function drive(jobId, workspaceId, runners) {
8688
8715
  } catch (error) {
8689
8716
  const kind = error instanceof GuideRejected || error instanceof KissRejected ? "agent_output_invalid" : error instanceof AgentRunError ? error.kind : void 0;
8690
8717
  const raw = message(error);
8691
- job = recordFailure(job, explain(job, raw), kind, raw);
8718
+ job = recordFailure(job, explain(job, raw), driver, kind, raw);
8692
8719
  if (job.reviewType === "kiss") failCurrentKissRun(workspaceId, job, job.failure?.message ?? raw, kind, error instanceof KissRejected ? error.answer : void 0);
8693
8720
  finished(job, workspaceId, startedAt, false);
8694
8721
  return job;
@@ -10735,4 +10762,4 @@ function relativeTime(ms) {
10735
10762
  return `${days} day${days === 1 ? "" : "s"} ago`;
10736
10763
  }
10737
10764
  //#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 };
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 };
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as checkCodeFreshness, A as agentEnvOf, At as processDiscussion, B as BUILD_VERSION, Bt as STORE_ROOT, C as statedWorkspacePort, Ct as touchWorkspace, D as refreshPrContext, Dt as issuesOutstanding, E as updateReview, Et as isQuestionOutstanding, H as feedbackUrl, I as readEvents, It as clearAgentPreference, K as fillFileUrlTemplate, Kt as AgentCancelledError, L as record, Lt as readAgentPreference, M as startKissJob, O as isUnchanged, Ot as questionsOutstanding, P as runJob, Pt as git, Q as removeWorktree, R as recordTelemetryUploadConsent, Rt as writeAgentPreference, S as resolveWorkspacePort, T as RefreshUnavailableError, Tt as isIssueOutstanding, U as withUsageRecorded, Vt as AGENT_IDS, W as EXPLAIN_SIMPLY_PROMPT, Wt as agentFor, X as MANAGED_ROOT, Y as isClaimed, Z as readMarker, _ as writeServerRecord, _t as recentReviewGroups, a as liveJobsFor, at as saveJob, b as MIN_WORKSPACE_PORT, c as discardJob, ct as followedRefName, d as clearServerRecord, et as checkFreshness, ft as lineagePosition, gt as previousKissResult, i as lineageIds, it as loadJob, jt as describeAuthorship, k as askCheckout, kt as reviewerDispositions, l as readIndexToken, n as deleteLineage, nt as fail, o as wasBlocked, ot as PHASES, p as readServerRecord, pt as loadWorkspace, q as ReviewBeingDeletedError, qt as AgentUnavailableError, r as deleteReview, rt as isTerminal, st as progressSteps, t as relativeTime$1, tt as allJobs, ut as latestKissRun, v as DEFAULT_WORKSPACE_PORT, w as writePortPreference, wt as doneVerb, x as parseWorkspacePort, xt as saveWorkspace, y as MAX_WORKSPACE_PORT, yt as reviewedCommit, z as telemetryUploadConsent } from "./relative_time-CDvK_Zaj.js";
1
+ import { $ as allJobs, A as agentEnvOf, At as processDiscussion, B as BUILD_VERSION, Bt as STORE_ROOT, C as statedWorkspacePort, Ct as touchWorkspace, D as refreshPrContext, Dt as issuesOutstanding, E as updateReview, Et as isQuestionOutstanding, H as feedbackUrl, I as readEvents, It as clearAgentPreference, J as MANAGED_ROOT, K as fillFileUrlTemplate, Kt as AgentCancelledError, L as record, Lt as readAgentPreference, M as startKissJob, O as isUnchanged, Ot as questionsOutstanding, P as runJob, Pt as git, Q as checkFreshness, R as recordTelemetryUploadConsent, Rt as writeAgentPreference, S as resolveWorkspacePort, T as RefreshUnavailableError, Tt as isIssueOutstanding, U as withUsageRecorded, Vt as AGENT_IDS, W as EXPLAIN_SIMPLY_PROMPT, Wt as agentFor, X as removeWorktree, Y as readMarker, Z as checkCodeFreshness, _ as writeServerRecord, _t as recentReviewGroups, a as liveJobsFor, at as progressSteps, b as MIN_WORKSPACE_PORT, c as discardJob, ct as followedRefName, d as clearServerRecord, et as fail, ft as lineagePosition, gt as previousKissResult, i as lineageIds, it as PHASES, jt as describeAuthorship, k as askCheckout, kt as reviewerDispositions, l as readIndexToken, n as deleteLineage, nt as loadJob, o as wasBlocked, p as readServerRecord, pt as loadWorkspace, q as ReviewBeingDeletedError, qt as AgentUnavailableError, r as deleteReview, rt as saveJob, st as isClaimed, t as relativeTime$1, tt as isTerminal, ut as latestKissRun, v as DEFAULT_WORKSPACE_PORT, w as writePortPreference, wt as doneVerb, x as parseWorkspacePort, xt as saveWorkspace, y as MAX_WORKSPACE_PORT, yt as reviewedCommit, z as telemetryUploadConsent } from "./relative_time-D760FOL9.js";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
4
  import { timingSafeEqual } from "node:crypto";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prreviewbuddy",
3
- "version": "0.25.7",
3
+ "version": "0.25.8",
4
4
  "description": "Review a branch or pull request in an isolated checkout, from your terminal, with the coding agent you already have installed.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://prreviewbuddy.com",