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.
- package/CHANGELOG.md +115 -0
- package/dist/main.js +76 -9
- package/dist/{relative_time-CDvK_Zaj.js → relative_time-vn8Ex9E8.js} +847 -505
- package/dist/server.js +312 -42
- package/package.json +1 -1
- package/static/chunk-B80S6zbH.js +4 -4
- package/static/chunk-CIsM3PMS.js +1 -0
- package/static/{chunk-B9H2wUlQ.js → chunk-gBDI1-Cy.js} +1 -1
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +48 -0
- package/static/workspace.js +44 -44
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
2193
|
-
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3933
|
-
*
|
|
3934
|
-
*
|
|
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
|
-
*
|
|
3939
|
-
*
|
|
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
|
-
*
|
|
4048
|
+
* One directory for every kind of claim, keyed by name.
|
|
3949
4049
|
*
|
|
3950
|
-
*
|
|
3951
|
-
*
|
|
3952
|
-
*
|
|
3953
|
-
*
|
|
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
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
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
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
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
|
-
*
|
|
4069
|
+
* Whether the process behind a claim still exists.
|
|
3981
4070
|
*
|
|
3982
|
-
*
|
|
3983
|
-
*
|
|
3984
|
-
*
|
|
3985
|
-
*
|
|
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
|
|
4086
|
+
* The claim on this key, if one is genuinely held.
|
|
3989
4087
|
*
|
|
3990
|
-
*
|
|
3991
|
-
*
|
|
3992
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
4101
|
+
* Take the claim on a key, or return null because somebody else holds it.
|
|
3997
4102
|
*
|
|
3998
|
-
*
|
|
3999
|
-
*
|
|
4000
|
-
*
|
|
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
|
-
*
|
|
4005
|
-
*
|
|
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
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
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,
|
|
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
|
-
*
|
|
4103
|
-
*
|
|
4104
|
-
*
|
|
4105
|
-
*
|
|
4106
|
-
*
|
|
4107
|
-
*
|
|
4108
|
-
*
|
|
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
|
-
|
|
4113
|
-
|
|
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
|
-
|
|
4893
|
-
|
|
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
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
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
|
-
/**
|
|
4921
|
-
function
|
|
4922
|
-
|
|
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
|
-
*
|
|
4926
|
-
*
|
|
4927
|
-
*
|
|
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
|
|
4932
|
-
|
|
5162
|
+
function realpathOrSelf(path) {
|
|
5163
|
+
try {
|
|
5164
|
+
return realpathSync(path);
|
|
5165
|
+
} catch {
|
|
5166
|
+
return resolve(path);
|
|
5167
|
+
}
|
|
4933
5168
|
}
|
|
4934
|
-
|
|
4935
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 $,
|
|
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 };
|