prreviewbuddy 0.78.1 → 0.80.2

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,103 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.80.2
4
+
5
+ **A review opens the way you left the last one.** Hiding or showing Buddy AI, expanding a pane or
6
+ Buddy AI, and folding parts of the rail are remembered across reviews until you change them. An
7
+ expanded pane carries over as "the document expanded", so the next review opens with whichever pane
8
+ it lands on expanded. Reloading a review, or going Back or Forward to it, keeps that page's own
9
+ layout and changes nothing about how the next review opens. On a narrow window, where Buddy AI would
10
+ cover the review, it stays closed and unexpanded until you open it.
11
+
12
+ **Activity, Settings and Help match the other pages.** Activity's list keeps the same width as the
13
+ Reviews and Runs pages instead of filling the window. Settings and Help sit in the same light,
14
+ rounded border as Activity's list.
15
+
16
+ ## 0.80.1
17
+
18
+ **Runs filters say what they filter.** Status and Operation are now Run status and Run type, so
19
+ neither reads as the review's own state, and their first options say All statuses and All types. A
20
+ first analysis is called Initial review, in the filter and on each run, to set it apart from Update
21
+ and Re-run. A click anywhere on a Runs filter opens its menu, as it does on the Reviews page, rather
22
+ than only a click on its value.
23
+
24
+ **Runs sections fold, and stay folded.** In progress and Recent runs fold from their headings, or
25
+ with ⌘K L then I or R, as the Reviews page's sections do, and stay as you left them through the
26
+ page's live reloads and the next visit. The Runs page keeps its own folds: folding In progress here
27
+ leaves it open on Reviews. Fixed: Show older runs no longer appears when every run is already shown.
28
+
29
+ **A finished run's card opens its review.** Click anywhere on it, or move to it with J and K and
30
+ press Enter, as on Reviews. The Open review link is gone. Remove now sits in the card's … menu, as
31
+ Remove from Runs, which Space opens from the keyboard. A run whose review was deleted still says so
32
+ and opens nothing.
33
+
34
+ **Old run records are deleted even when their checkout is already gone.** A record past retention
35
+ whose checkout had been removed some other way was refused on every cleanup and kept for good, since
36
+ the removal guard found no marker in a directory that was not there. The Runs page's cleanup note was
37
+ the first place this showed. They now go on the next pass. A checkout that cannot be looked at, as
38
+ opposed to one that is gone, still keeps its record.
39
+
40
+ ## 0.80.0
41
+
42
+ **Runs: what is running, what is waiting, and a week of what ran.** A new Runs page, beside Reviews
43
+ in the sidebar and on ⌘K E from anywhere, lists every analysis, Update review, re-run and KISS
44
+ review as its own entry, whether it was started here or from a terminal. Running work shows the step
45
+ it is on; the pull requests you added from the workspace that are waiting for a slot show as Queued.
46
+ Below that, every run of the last seven days says how it ended (Completed, Failed or Cancelled), when,
47
+ how long it took, who asked for it (Workspace or CLI) and which agent ran it, with the reason when it
48
+ failed. A review analysed, then updated, then re-run is three entries, and a Retry is a new entry
49
+ rather than a rewrite of the attempt it retried.
50
+
51
+ Three counts sit above the lists: Running, Queued, and Failed over the last seven days. Search,
52
+ Status and Operation filters search the whole week, not only the runs on screen, and stay set while
53
+ the page refreshes itself as runs start and end. Open review opens the review as it is now; when the
54
+ review presents a later re-run's analysis instead of a run's own, that run says so. A re-run still going, or one that failed or was stopped, replaces nothing, and
55
+ neither does an update, which changes an analysis in place. Remove takes an ended run off the
56
+ page and changes nothing about its review; it is not offered on a run whose job is running again.
57
+
58
+ A run whose driver has gone, such as a terminal that was closed mid-review, is shown as Not
59
+ responding rather than as running, and reads Failed once the existing half-hour check gives up on it.
60
+
61
+ **What is kept.** Finished runs are now kept for seven days; their isolated checkouts are still
62
+ removed after six hours, and Retry on an older failure makes a new checkout. A review that failed
63
+ therefore keeps saying "Analysis failed" for up to seven days rather than six hours. Deleting a review
64
+ keeps a record that its runs happened, with none of their output, the conversation or the agent's
65
+ words, until the seven days are up; the entry reads "Review deleted" and links nowhere. Nothing older
66
+ than seven days is ever shown. Old records and checkouts are deleted by the workspace server, once
67
+ when it starts and every ten minutes while it runs, so a server left stopped deletes nothing until
68
+ it next starts. If a cleanup cannot remove something, the Runs page says what and why, an open
69
+ page included, and the next pass tries again.
70
+
71
+ Queued work is this server's own: pull requests waiting in a terminal's `--pr` batch appear once
72
+ they start, as on the Reviews page.
73
+
74
+ ## 0.79.0
75
+
76
+ **In progress: everything being analysed, in one place.** Reviews now opens with an In progress
77
+ section whenever work is going on. It lists every review being analysed on this machine, whether you
78
+ started it here or from a terminal, every Update review and re-run, and the pull requests you added
79
+ from the workspace that are waiting for their turn, each with the step it is on and when it started.
80
+ Pull requests waiting in a terminal's `--pr` batch appear once they start. Queued entries hold
81
+ still; running ones pulse. Click an entry to open the review, where its progress, Cancel and Retry
82
+ already are. A pull request still waiting has no review yet, so its entry is not a link.
83
+
84
+ A review you are updating stays in Active with its current findings while the update runs, and also
85
+ appears under In progress, because one is the review and the other is the work being done to it. A
86
+ review being analysed for the first time has nothing to show yet, so it is listed only under In
87
+ progress, and moves to Active when its analysis finishes. If the analysis fails or is stopped, it
88
+ returns to Active saying so, as before.
89
+
90
+ The section follows the open page without a refresh: work appears as it starts, moves from Queued to
91
+ running, and leaves when it ends. Search and the Author, Review state and PR filters apply to it just
92
+ as they do to the reviews, its count is of the work it is showing, and its heading folds like the
93
+ others, from the keyboard too with ⌘K L I.
94
+
95
+ ## 0.78.2
96
+
97
+ **A long prompt wraps instead of scrolling sideways.** In a finding's Ask Buddy menu and in the menu
98
+ over selected text, a saved prompt longer than the menu's width now continues onto further lines,
99
+ so all of it can be read before choosing. The menu over selected text stays at most 320px wide.
100
+
3
101
  ## 0.78.1
4
102
 
5
103
  **A filtered list says it is filtered.** While the search or a filter is narrowing Reviews, the count
@@ -1,6 +1,6 @@
1
1
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
- import { accessSync, appendFileSync, closeSync, constants, existsSync, linkSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
+ import { accessSync, appendFileSync, closeSync, constants, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { homedir, hostname, tmpdir } from "node:os";
6
6
  import { promisify } from "node:util";
@@ -3910,7 +3910,7 @@ function* jsonCandidates(text) {
3910
3910
  for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
3911
3911
  const inner = match[1].trim();
3912
3912
  if (!inner.startsWith("{") && !inner.startsWith("[")) continue;
3913
- const value = parsed(inner);
3913
+ const value = parsed$1(inner);
3914
3914
  if (value.ok) yield {
3915
3915
  text: inner,
3916
3916
  value: value.value
@@ -3922,7 +3922,7 @@ function* jsonCandidates(text) {
3922
3922
  const end = closingIndex(text, start, opener);
3923
3923
  if (end === -1) continue;
3924
3924
  const candidate = text.slice(start, end + 1);
3925
- const value = parsed(candidate);
3925
+ const value = parsed$1(candidate);
3926
3926
  if (!value.ok) continue;
3927
3927
  yield {
3928
3928
  text: candidate,
@@ -3931,7 +3931,7 @@ function* jsonCandidates(text) {
3931
3931
  start = end;
3932
3932
  }
3933
3933
  }
3934
- function parsed(candidate) {
3934
+ function parsed$1(candidate) {
3935
3935
  try {
3936
3936
  return {
3937
3937
  ok: true,
@@ -6217,6 +6217,11 @@ function summarise(workspace) {
6217
6217
  questions: questions.length,
6218
6218
  unansweredQuestions: questionsOutstanding(questions, answered).open,
6219
6219
  messages: workspace.transcript.length,
6220
+ ...workspace.jobId && result ? { analysisJobId: workspace.jobId } : {},
6221
+ ...(() => {
6222
+ const kiss = presentedKissRunOf(workspace);
6223
+ return kiss ? { presentedKissJobId: kiss.jobId } : {};
6224
+ })(),
6220
6225
  lastViewedAt: workspace.lastViewedAt,
6221
6226
  completedAt: workspace.completedAt,
6222
6227
  pinned: workspace.pinned === true,
@@ -6477,6 +6482,17 @@ function workspaceIdFrom(entry) {
6477
6482
  * the other's file by accident.
6478
6483
  */
6479
6484
  var CLAIMS_DIR = join(STORE_ROOT, "claims");
6485
+ /**
6486
+ * How long a claim stands without being renewed.
6487
+ *
6488
+ * Deliberately short, and it can be short because renewal is on a timer rather than tied to the
6489
+ * work. The job record's own `updatedAt` moves only when a phase changes or the agent streams a
6490
+ * line, which is why the sweep has to allow it thirty minutes of silence; a heartbeat keeps ticking
6491
+ * through a twenty minute analysis that says nothing at all. Three minutes leaves room for several
6492
+ * missed renewals on an event loop busy parsing a large diff, and still frees a crashed holder long
6493
+ * before anyone would think to ask why Retry does nothing.
6494
+ */
6495
+ var CLAIM_LEASE_MS = 18e4;
6480
6496
  /** Comfortably inside the lease, so one slow tick is never enough to lose a claim that is held. */
6481
6497
  var CLAIM_RENEW_MS = 3e4;
6482
6498
  function claimPath(key) {
@@ -6882,6 +6898,128 @@ var JOBS_DIR = join(STORE_ROOT, "jobs");
6882
6898
  function reusesImportedConversation(job) {
6883
6899
  return job.reviewType === "kiss";
6884
6900
  }
6901
+ /**
6902
+ * What survives of a finished run when the reviewer deletes its review.
6903
+ *
6904
+ * Runs is a history of executions, not of reviews, so "the review you deleted was analysed twice
6905
+ * yesterday and the first attempt failed" stays true after the deletion. What the reviewer deleted
6906
+ * must not: everything a run produced or was given goes, and so does every sentence that might
6907
+ * quote it. Kept are identity and times, the outcome as a kind rather than a message, which client
6908
+ * asked and which agent ran, and the change's own name (repository, branch, pull request), which
6909
+ * is what makes the entry recognisable at all.
6910
+ *
6911
+ * An allow-list, built field by field, so a field added to the job later is dropped here until
6912
+ * somebody decides it should not be.
6913
+ */
6914
+ function strippedForDeletedReview(job) {
6915
+ const request = job.target.pullRequest;
6916
+ const attemptFailure = (failure) => failure ? { failure: {
6917
+ message: "",
6918
+ ...failure.kind ? { kind: failure.kind } : {},
6919
+ ...failure.stopped ? { stopped: true } : {}
6920
+ } } : {};
6921
+ return {
6922
+ id: job.id,
6923
+ workspaceId: job.workspaceId,
6924
+ target: {
6925
+ originRepoPath: job.target.originRepoPath,
6926
+ branch: job.target.branch,
6927
+ sha: job.target.sha,
6928
+ ...request ? { pullRequest: {
6929
+ reference: request.url ?? "",
6930
+ headRef: request.headRef,
6931
+ headSha: request.headSha,
6932
+ ...request.number !== void 0 ? { number: request.number } : {},
6933
+ ...request.url ? { url: request.url } : {},
6934
+ ...request.source ? { source: request.source } : {}
6935
+ } } : {}
6936
+ },
6937
+ ...job.purpose ? { purpose: job.purpose } : {},
6938
+ ...job.reanalysis ? { reanalysis: true } : {},
6939
+ ...job.reviewType ? { reviewType: job.reviewType } : {},
6940
+ phase: job.phase,
6941
+ startedAt: job.startedAt,
6942
+ updatedAt: job.updatedAt,
6943
+ ...job.finishedAt !== void 0 ? { finishedAt: job.finishedAt } : {},
6944
+ completed: [],
6945
+ ...job.failure ? { failure: {
6946
+ phase: job.failure.phase,
6947
+ message: "",
6948
+ ...job.failure.kind ? { kind: job.failure.kind } : {},
6949
+ ...job.failure.stopped ? { stopped: true } : {}
6950
+ } } : {},
6951
+ env: job.env.agentId ? { agentId: job.env.agentId } : {},
6952
+ attemptId: attemptIdOf(job),
6953
+ ...job.source ? { source: job.source } : {},
6954
+ ...job.previousAttempts ? { previousAttempts: job.previousAttempts.map((attempt) => ({
6955
+ attemptId: attempt.attemptId,
6956
+ startedAt: attempt.startedAt,
6957
+ finishedAt: attempt.finishedAt,
6958
+ phase: attempt.phase,
6959
+ ...attemptFailure(attempt.failure),
6960
+ ...attempt.source ? { source: attempt.source } : {},
6961
+ ...attempt.agentId ? { agentId: attempt.agentId } : {}
6962
+ })) } : {},
6963
+ ...job.removedFromRuns ? { removedFromRuns: job.removedFromRuns } : {},
6964
+ reviewDeleted: true
6965
+ };
6966
+ }
6967
+ /**
6968
+ * How long a finished run is listed, and kept: measured from when it ended, per attempt.
6969
+ *
6970
+ * A strict limit on what the page shows, not only on what the sweep deletes. The sweep runs inside
6971
+ * the workspace server, so while the server is stopped nothing is deleted, and a record can outlive
6972
+ * its week on disk until the next start; the page never shows it past the week regardless.
6973
+ */
6974
+ var RUN_RECORD_RETENTION_MS = 6048e5;
6975
+ /** The current attempt's id. See `ReviewJob.attemptId`. */
6976
+ function attemptIdOf(job) {
6977
+ return job.attemptId ?? job.id;
6978
+ }
6979
+ /**
6980
+ * The record a driver should save as it picks this job up, before it does any work.
6981
+ *
6982
+ * A job that had ended is starting a new attempt: the ended one is archived, a new attempt id is
6983
+ * minted and the record leaves its terminal phase, all in the one value returned, so the caller
6984
+ * makes a single write. That is what makes the archive happen exactly once. A crash after the write
6985
+ * leaves a working job nobody drives, which the sweep in time records as stopped, and never a
6986
+ * terminal one that the next pickup would archive a second time; the id check covers a record that
6987
+ * was archived and then, somehow, written back terminal.
6988
+ *
6989
+ * A job that had not ended is the same attempt resuming (a restarted daemon, a driver whose claim
6990
+ * lapsed): only `startedAt` moves, because the page times "Running for" from it.
6991
+ */
6992
+ function beginAttempt(job, next) {
6993
+ if (!isTerminal(job)) return {
6994
+ ...job,
6995
+ startedAt: next.startedAt
6996
+ };
6997
+ const ended = attemptIdOf(job);
6998
+ const earlier = job.previousAttempts ?? [];
6999
+ const current = earlier.filter((attempt) => next.startedAt - attempt.finishedAt < RUN_RECORD_RETENTION_MS);
7000
+ const archived = earlier.some((attempt) => attempt.attemptId === ended) ? current : [...current, {
7001
+ attemptId: ended,
7002
+ startedAt: job.startedAt,
7003
+ finishedAt: endedAt(job),
7004
+ phase: job.phase,
7005
+ ...job.failure ? { failure: {
7006
+ message: job.failure.message,
7007
+ ...job.failure.kind ? { kind: job.failure.kind } : {},
7008
+ ...job.failure.stopped ? { stopped: true } : {}
7009
+ } } : {},
7010
+ ...job.source ? { source: job.source } : {},
7011
+ ...job.env.agentId ? { agentId: job.env.agentId } : {}
7012
+ }];
7013
+ const { failure: _failure, finishedAt: _finishedAt, progress: _progress, ...rest } = job;
7014
+ return {
7015
+ ...rest,
7016
+ phase: next.phase,
7017
+ startedAt: next.startedAt,
7018
+ attemptId: randomUUID(),
7019
+ previousAttempts: archived,
7020
+ ...next.source ? { source: next.source } : {}
7021
+ };
7022
+ }
6885
7023
  function jobPath(id) {
6886
7024
  return join(JOBS_DIR, `${id}.json`);
6887
7025
  }
@@ -6889,6 +7027,7 @@ function createJob(input) {
6889
7027
  const now = Date.now();
6890
7028
  return {
6891
7029
  id: randomUUID(),
7030
+ attemptId: randomUUID(),
6892
7031
  phase: "preparing",
6893
7032
  startedAt: now,
6894
7033
  updatedAt: now,
@@ -6896,9 +7035,30 @@ function createJob(input) {
6896
7035
  ...input
6897
7036
  };
6898
7037
  }
7038
+ /**
7039
+ * Replace the record whole, so a reader sees the old one or the new one and never a prefix.
7040
+ *
7041
+ * `writeFileSync` truncates on open, and a crash between that and the end of the write left a record
7042
+ * that does not parse, which `loadJob` reads as no job at all: the review's analysis, its attempts
7043
+ * and its checkout's only pointer gone at once. Renamed into place instead, from a name unique to
7044
+ * this write, because more than one process writes job records. Serialised first, so a throw in
7045
+ * `JSON.stringify` cannot leave anything behind. Unlike `writeJsonAtomically`, a failure throws:
7046
+ * every caller of this already handles a write that fails.
7047
+ */
6899
7048
  function saveJob(job) {
7049
+ const document = JSON.stringify(job);
6900
7050
  mkdirSync(JOBS_DIR, { recursive: true });
6901
- writeFileSync(jobPath(job.id), JSON.stringify(job), { mode: 384 });
7051
+ const path = jobPath(job.id);
7052
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
7053
+ try {
7054
+ writeFileSync(temp, document, { mode: 384 });
7055
+ renameSync(temp, path);
7056
+ } catch (error) {
7057
+ try {
7058
+ unlinkSync(temp);
7059
+ } catch {}
7060
+ throw error;
7061
+ }
6902
7062
  }
6903
7063
  function loadJob(id) {
6904
7064
  try {
@@ -6925,21 +7085,81 @@ function migrate(job) {
6925
7085
  }
6926
7086
  };
6927
7087
  }
7088
+ /**
7089
+ * Every record parsed so far, by file, with the identity of the file it was parsed from.
7090
+ *
7091
+ * Since the Runs page, the directory holds a week of records rather than six hours of them, and the
7092
+ * Reviews and Runs pages each read all of it on every poll. Read and parsed whole, 500 records cost
7093
+ * about 100 ms a time, synchronously, inside the server; asking the filesystem whether each file is
7094
+ * still the one already parsed costs about 1 ms. `saveJob` renames a fresh file into place, so a
7095
+ * record that was written in any way has a new inode, and inode, mtime and size together are a
7096
+ * change no write can slip past.
7097
+ *
7098
+ * Frozen, because every caller now shares one copy: a caller that edited a record in place would
7099
+ * be editing it for everyone, and strict mode turns that into an error where it happens.
7100
+ */
7101
+ var parsed = /* @__PURE__ */ new Map();
6928
7102
  function allJobs() {
6929
7103
  let names;
6930
7104
  try {
6931
7105
  names = readdirSync(JOBS_DIR);
6932
7106
  } catch {
7107
+ parsed.clear();
6933
7108
  return [];
6934
7109
  }
6935
7110
  const jobs = [];
7111
+ const present = /* @__PURE__ */ new Set();
6936
7112
  for (const name of names) {
6937
7113
  if (!name.endsWith(".json")) continue;
7114
+ present.add(name);
7115
+ let identity;
7116
+ try {
7117
+ const stat = statSync(join(JOBS_DIR, name));
7118
+ identity = `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
7119
+ } catch {
7120
+ continue;
7121
+ }
7122
+ const cached = parsed.get(name);
7123
+ if (cached?.identity === identity) {
7124
+ jobs.push(cached.job);
7125
+ continue;
7126
+ }
6938
7127
  const job = loadJob(name.slice(0, -5));
6939
- if (job) jobs.push(job);
7128
+ if (!job) {
7129
+ parsed.delete(name);
7130
+ continue;
7131
+ }
7132
+ Object.freeze(job);
7133
+ parsed.set(name, {
7134
+ identity,
7135
+ job
7136
+ });
7137
+ jobs.push(job);
6940
7138
  }
7139
+ for (const name of parsed.keys()) if (!present.has(name)) parsed.delete(name);
6941
7140
  return jobs;
6942
7141
  }
7142
+ /**
7143
+ * Temporary files a `saveJob` left behind when its process died between the write and the rename.
7144
+ *
7145
+ * Invisible to every reader, which lists `.json` only, so they cost nothing but disk. A write lasts
7146
+ * milliseconds, so one older than `olderThanMs` is residue rather than a write in flight.
7147
+ */
7148
+ function pruneJobResidue(now, olderThanMs) {
7149
+ let names;
7150
+ try {
7151
+ names = readdirSync(JOBS_DIR);
7152
+ } catch {
7153
+ return;
7154
+ }
7155
+ for (const name of names) {
7156
+ if (!name.endsWith(".tmp")) continue;
7157
+ const path = join(JOBS_DIR, name);
7158
+ try {
7159
+ if (now - statSync(path).mtimeMs >= olderThanMs) unlinkSync(path);
7160
+ } catch {}
7161
+ }
7162
+ }
6943
7163
  function deleteJob(id) {
6944
7164
  try {
6945
7165
  unlinkSync(jobPath(id));
@@ -15938,6 +16158,7 @@ async function startJob(input) {
15938
16158
  target,
15939
16159
  env: agent.captureEnv(),
15940
16160
  purpose: "review",
16161
+ ...input.source ? { source: input.source } : {},
15941
16162
  ...input.requestedModel ? { requestedModel: input.requestedModel } : {},
15942
16163
  ...review.type === "kiss" ? {
15943
16164
  reviewType: "kiss",
@@ -15971,7 +16192,7 @@ async function startJob(input) {
15971
16192
  * The previous result is snapshotted before the session is replaced, so the review keeps saying who
15972
16193
  * made the analysis it is retiring as well as who made the one it now shows.
15973
16194
  */
15974
- async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel) {
16195
+ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel, source) {
15975
16196
  const workspace = loadWorkspace(workspaceId);
15976
16197
  if (!workspace) throw new Error("No such review.");
15977
16198
  const sha = workspace.session.metadata?.commitSha;
@@ -16003,6 +16224,7 @@ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel)
16003
16224
  env: agent.captureEnv(),
16004
16225
  purpose: "review",
16005
16226
  reanalysis: true,
16227
+ ...source ? { source } : {},
16006
16228
  ...model ? { requestedModel: model } : {}
16007
16229
  });
16008
16230
  saveJob(job);
@@ -16062,7 +16284,7 @@ async function runJob(workspaceId, runners = {}, reviewType = "standard", option
16062
16284
  outer?.addEventListener("abort", () => controller.abort(), { once: true });
16063
16285
  const unwatch = watchForStop(started.jobId, controller);
16064
16286
  try {
16065
- return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder, controller.signal);
16287
+ return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder, controller.signal, options.source);
16066
16288
  } finally {
16067
16289
  unwatch();
16068
16290
  clearJobEnding(started.jobId);
@@ -16117,16 +16339,18 @@ function runOfJob(workspace, jobId) {
16117
16339
  if (!run) throw new Error("This KISS job has no run to write its answer to.");
16118
16340
  return run;
16119
16341
  }
16120
- async function drive(jobId, workspaceId, runners, driver, signal) {
16342
+ async function drive(jobId, workspaceId, runners, driver, signal, source) {
16121
16343
  let job = loadJob(jobId);
16122
16344
  if (!job) throw new Error("No such job.");
16123
16345
  const now = runners.now ?? Date.now;
16124
16346
  const startedAt = now();
16125
- const from = resumeFrom(job);
16126
- job = {
16127
- ...job,
16128
- startedAt
16129
- };
16347
+ const from = checkoutGone$1(job) ? "preparing" : resumeFrom(job);
16348
+ job = beginAttempt(job, {
16349
+ startedAt,
16350
+ phase: from,
16351
+ ...source ? { source } : {}
16352
+ });
16353
+ saveJob(job);
16130
16354
  try {
16131
16355
  signal?.throwIfAborted();
16132
16356
  if (needs(from, "preparing")) {
@@ -16356,6 +16580,18 @@ function explain(job, detail) {
16356
16580
  }
16357
16581
  }
16358
16582
  /** True when this phase is at or after the point the job resumes from. */
16583
+ /**
16584
+ * The record says work was done in a checkout that is no longer there.
16585
+ *
16586
+ * The sweep removes a finished job's checkout after six hours and keeps its record for seven days,
16587
+ * so a Retry of an older failure finds `preparing` complete and nothing on disk. Starting from
16588
+ * `preparing` makes the checkout again at the job's commit, and everything after it with it, because
16589
+ * every later phase reads files from it.
16590
+ */
16591
+ function checkoutGone$1(job) {
16592
+ if (!job.completed.includes("preparing")) return false;
16593
+ return !job.worktreePath || !existsSync(join(job.worktreePath, ".prreviewbuddy-worktree.json"));
16594
+ }
16359
16595
  function needs(from, phase) {
16360
16596
  return WORK_PHASES.indexOf(phase) >= WORK_PHASES.indexOf(from);
16361
16597
  }
@@ -16642,7 +16878,7 @@ function failureOf(workspaceId) {
16642
16878
  * "whatever the default is", so a configured default of Codex announced Codex and then analysed
16643
16879
  * with Claude, and nothing said so until the failure named the wrong tool.
16644
16880
  */
16645
- async function prepareReview(target, agentId, fresh, requestedModel) {
16881
+ async function prepareReview(target, agentId, fresh, requestedModel, source) {
16646
16882
  if (!fresh) {
16647
16883
  const existing = await findExistingReview(target);
16648
16884
  if (existing) {
@@ -16662,7 +16898,8 @@ async function prepareReview(target, agentId, fresh, requestedModel) {
16662
16898
  workspaceId: await startJob({
16663
16899
  target,
16664
16900
  agentId,
16665
- ...requestedModel ? { requestedModel } : {}
16901
+ ...requestedModel ? { requestedModel } : {},
16902
+ ...source ? { source } : {}
16666
16903
  })
16667
16904
  };
16668
16905
  }
@@ -16679,10 +16916,13 @@ async function prepareReview(target, agentId, fresh, requestedModel) {
16679
16916
  * that reads as a clean bill of health, which is the opposite of what happened. A KISS run's
16680
16917
  * answer is checked by `drive`, which knows which run it is.
16681
16918
  */
16682
- async function settleReview(workspaceId, reviewType = "standard", signal) {
16919
+ async function settleReview(workspaceId, reviewType = "standard", signal, source) {
16683
16920
  let job;
16684
16921
  try {
16685
- job = await runJob(workspaceId, {}, reviewType, signal ? { signal } : {});
16922
+ job = await runJob(workspaceId, {}, reviewType, {
16923
+ ...signal ? { signal } : {},
16924
+ ...source ? { source } : {}
16925
+ });
16686
16926
  } catch (error) {
16687
16927
  return {
16688
16928
  ok: false,
@@ -17645,6 +17885,7 @@ async function recordKissRun(input) {
17645
17885
  purpose: "review",
17646
17886
  reviewType: "kiss",
17647
17887
  audience: input.audience,
17888
+ ...input.source ? { source: input.source } : {},
17648
17889
  ...requestedModel ? { requestedModel } : {}
17649
17890
  });
17650
17891
  saveJob(job);
@@ -18608,6 +18849,7 @@ async function updateReview(workspace, runners = {}) {
18608
18849
  if (workspace.changeSet.targetType === "commit") return refuse(reviewedSha, false, COMMIT_REVIEW_CANNOT_UPDATE);
18609
18850
  const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
18610
18851
  purpose: "update",
18852
+ ...runners.source ? { source: runners.source } : {},
18611
18853
  ...workspace.prContext ? { conversation: {
18612
18854
  kind: "attached",
18613
18855
  context: workspace.prContext
@@ -19298,7 +19540,7 @@ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgre
19298
19540
  * nothing is running has answered the same question better, and passes 0.
19299
19541
  */
19300
19542
  async function discardJob(job, options = {}) {
19301
- if (job.worktreePath) {
19543
+ if (job.worktreePath && !checkoutGone(job.worktreePath)) {
19302
19544
  const outcome = await removeWorktree({
19303
19545
  originRepoPath: job.target.originRepoPath,
19304
19546
  worktreePath: job.worktreePath,
@@ -19313,6 +19555,18 @@ async function discardJob(job, options = {}) {
19313
19555
  clearClaim(job.id);
19314
19556
  return { removed: true };
19315
19557
  }
19558
+ /**
19559
+ * Only when the filesystem says there is nothing there. Any other failure to look, a permission
19560
+ * error say, is not proof of absence, and the record is the only thing that names the directory.
19561
+ */
19562
+ function checkoutGone(path) {
19563
+ try {
19564
+ lstatSync(path);
19565
+ return false;
19566
+ } catch (error) {
19567
+ return error.code === "ENOENT";
19568
+ }
19569
+ }
19316
19570
  //#endregion
19317
19571
  //#region ../../packages/review-harness/src/workspace/delete_review.ts
19318
19572
  /**
@@ -19396,13 +19650,33 @@ async function deleteReview(id) {
19396
19650
  *
19397
19651
  * The record is unlinked **whether or not** a checkout was refused, and that is deliberate. The
19398
19652
  * review is what was asked for; a directory the guard declined keeps its job record, so the sweep
19399
- * comes back to it in six hours with the origin repository still recorded, and the refusal is
19653
+ * comes back to it once the checkout is past retention, with the origin repository still recorded, and the refusal is
19400
19654
  * reported so nobody has to discover it later.
19401
19655
  */
19402
19656
  async function deleteLocked(id) {
19403
19657
  const report = emptyReport();
19404
19658
  if (!loadWorkspace(id)) return report;
19405
19659
  for (const job of allJobs().filter((job) => job.workspaceId === id)) {
19660
+ if (isTerminal(job) && purposeOf(job) !== "ask") {
19661
+ if (job.worktreePath && existsSync(job.worktreePath)) {
19662
+ const outcome = await removeWorktree({
19663
+ originRepoPath: job.target.originRepoPath,
19664
+ worktreePath: job.worktreePath,
19665
+ minAgeMs: 0
19666
+ });
19667
+ if (!outcome.removed) {
19668
+ report.refusals.push(outcome.message);
19669
+ saveJob({
19670
+ ...strippedForDeletedReview(job),
19671
+ worktreePath: job.worktreePath
19672
+ });
19673
+ continue;
19674
+ }
19675
+ report.worktreesRemoved += 1;
19676
+ }
19677
+ saveJob(strippedForDeletedReview(job));
19678
+ continue;
19679
+ }
19406
19680
  const outcome = await discardJob(job, { minAgeMs: 0 });
19407
19681
  if (outcome.refusal) {
19408
19682
  report.refusals.push(outcome.refusal);
@@ -19461,4 +19735,4 @@ function lineageIds(id) {
19461
19735
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
19462
19736
  }
19463
19737
  //#endregion
19464
- export { resolveWorkspacePort as $, saveJob as $n, explanationActionOf as $r, pruneNotifications as $t, parsePullRequestLink as A, git as Ai, ReviewBeingDeletedError as An, activityAttributes as Ar, repositoryKey as At, ensureServer as B, agentById as Bi, findExistingReview as Bn, queueAddMany as Br, announceJob as Bt, completeRun as C, originRemote as Ci, GITHUB_MARKDOWN as Cn, reviewedCommit as Cr, RUNTIME_WORDS as Ct, relativeTime as D, AmbiguousRefError as Di, escapeHtml as Dn, workspaceRevision as Dr, loadArchitecture as Dt, stopRun as E, resolveBase as Ei, CODE_COPY_CLASS as En, touchWorkspace as Er, checkArchitecture as Et, queueForReview as F, readAgentPreference as Fi, readMarker as Fn, lifecyclePill as Fr, QUOTE_LIMITS as Ft, stopChild as G, fetchHead as Gn, isIssueOutstanding as Gr, createPrState as Gt, reviewsUrl as H, detectAgents as Hi, checkReview as Hn, queueStamp as Hr, subjectOf as Ht, settleQueue as I, writeAgentPreference as Ii, removeWorktree as In, offersLifecycle as Ir, REPOSITORY_SCOPE as It, writeServerRecord as J, allJobs as Jn, questionsOutstanding as Jr, listNotifications as Jt, stopServer as K, forgeResolver as Kn, isQuestionOutstanding as Kr, createPrUpdate as Kt, readIndexToken as L, CONFIG_PATH as Li, requestJobStop as Ln, parseLifecycle as Lr, ancestorsOf as Lt, runBatch as M, resolveAgent as Mi, createWorktree as Mn, isLifecycle as Mr, storedScopes as Mt, activateQueued as N, resolveModel as Ni, holdWorktree as Nn, lifecycleChip as Nr, trackedFiles as Nt, batchIdentity as O, shortNameOf as Oi, renderInlineText as On, workspaceStamps as Or, partFiles as Ot, compactQueue as P, clearAgentPreference as Pi, isWorktreeHeld as Pn, lifecycleItemLabel as Pr, ownersOf as Pt, parseWorkspacePort as Q, loadJob as Qn, recommendationsOf as Qr, moveIds as Qt, bootstrapUrl as R, STORE_ROOT as Ri, describeMovement as Rn, sectionOf as Rr, isArchitectureId as Rt, beginRun as S, resolveTarget as Si, hasAuthorContent as Sn, recentWorkspaces as Sr, recordRuntimeBestEffort as St, reportRun as T, isCommitReview as Ti, CODE_BLOCK_CLASS as Tn, summarise as Tr, ARCHITECTURE_DIR as Tt, serverEntryExists as U, AgentCancelledError as Ui, followedBranch as Un, readQueue as Ur, categoryOf as Ut, readServerRecord as V, agentFor as Vi, checkCodeFreshness as Vn, queueRemove as Vr, branchOf as Vt, spawnServer as W, AgentUnavailableError as Wi, reviewedShaOf as Wn, doneVerb as Wr, createArchitectureNotification as Wt, MAX_WORKSPACE_PORT as X, fail as Xn, reviewerDispositions as Xr, markRead as Xt, DEFAULT_WORKSPACE_PORT as Y, endedAt as Yn, resolvedByConversation as Yr, markAllRead as Yt, MIN_WORKSPACE_PORT as Z, isTerminal as Zn, processDiscussion as Zr, markReadForReview as Zt, recordKissRun as _, extractJsonText as _i, agentEnvOf as _n, matchingWorkspaceIds as _r, BUILD_VERSION as _t, wasBlocked as a, assessmentLookup as ai, unreadAround as an, clearClaim as ar, settleReview as at, track as b, WITHHELD_REASON as bi, UNESTABLISHED_MOTIVATION as bn, presentedKissRunOf as br, waitingHistory as bt, RefreshUnavailableError as c, findingIn as ci, existingPaths as cn, followedRefName as cr, startJob as ct, mergeUpdateInto as d, promptGrade as di, prepareConversationWithin as dn, indexReviewGroups as dr, record as dt, explanationOf as ei, readNotification as en, PHASES as er, statedWorkspacePort as et, updateReview as f, reassessIn as fi, saveRefresh as fn, kissDecisionRefusal as fr, stamp as ft, runningJobs as g, asString$1 as gi, isUnchanged as gn, loadWorkspace as gr, telemetryUploadEnabled as gt, liveUpdateFor as h, followUpFindingId as hi, refreshPrContext as hn, lineagePosition as hr, telemetryUploadConsent as ht, liveJobsFor as i, analysisRefOf as ii, toneOf as in, purposeOf as ir, prepareReview as it, unsupportedReason as j, refExists as ji, MANAGED_ROOT as jn, activityLabel as jr, storeArchitecture as jt, extractPullRequestLinks as k, shorthandOf as ki, modelHelpLines as kn, LIFECYCLES as kr, readArchitecture as kt, cancelUpdate as l, gradeScale as li, ASK_REREAD_CEILING_MS as ln, groupByLineage as lr, append as lt, UpdateAlreadyRunningError as m, wasReassessed as mi, unforeseenFailure as mn, latestKissRun as mr, recordTelemetryUploadConsent as mt, deleteReview as n, withExplanation as ni, removeNotification as nn, phasesFor as nr, failureOf as nt, discardJob as o, currentGradeIn as oi, unreadCount as on, isClaimed as or, reanalyseReview as ot, askCheckout as p, sourcePhrase as pi, storedConversation as pn, kissDecisionsOf as pr, deliver as pt, workspaceUrl as q, fillFileUrlTemplate as qn, issuesOutstanding as qr, eventName as qt, lineageIds as r, ANALYSIS_ONLY as ri, retireNotification as rn, progressSteps as rr, observeReview as rt, FETCH_TIMEOUT_MS as s, findingAssessment as si, withUsageRecorded as sn, decideKissRecommendation as sr, runJob as st, deleteLineage as t, isExplanationAction as ti, readStateOf as tn, isLiveJob as tr, writePortPreference as tt, cancellableHere as u, isGradeFor as ui, adoptLegacyBaseline as un, indexKeptIds as ur, readEvents as ut, startKissJob as v, objectWithAnyOf as vi, EXPLAIN_SIMPLY_PROMPT as vn, mutateWorkspace as vr, PACKAGE_NAME as vt, endRun as w, DetachedHeadError as wi, renderMarkdown as wn, reviewedRepositories as wr, describeRuntimes as wt, RUN_KINDS as x, kissOutcome as xi, authorPreview as xn, previousKissResult as xr, mapRuntimes as xt, isDriving as y, describeAuthorship as yi, chatConversationSection as yn, positionInLineage as yr, feedbackUrl as yt, clearServerRecordOwnedBy as z, AGENT_IDS as zi, describeSubject as zn, queueAdd as zr, writeJsonAtomically as zt };
19738
+ export { resolveWorkspacePort as $, AgentUnavailableError as $i, attemptIdOf as $n, isIssueOutstanding as $r, moveIds as $t, parsePullRequestLink as A, resolveTarget as Ai, modelHelpLines as An, recentWorkspaces as Ar, readArchitecture as At, ensureServer as B, resolveAgent as Bi, requestJobStop as Bn, isLifecycle as Br, writeJsonAtomically as Bt, completeRun as C, followUpFindingId as Ci, hasAuthorContent as Cn, lineagePosition as Cr, RUNTIME_WORDS as Ct, relativeTime as D, describeAuthorship as Di, CODE_COPY_CLASS as Dn, positionInLineage as Dr, checkArchitecture as Dt, stopRun as E, objectWithAnyOf as Ei, CODE_BLOCK_CLASS as En, mutateWorkspace as Er, ARCHITECTURE_DIR as Et, queueForReview as F, AmbiguousRefError as Fi, createWorktree as Fn, workspaceRevision as Fr, ownersOf as Ft, stopChild as G, CONFIG_PATH as Gi, checkReview as Gn, parseLifecycle as Gr, createArchitectureNotification as Gt, reviewsUrl as H, clearAgentPreference as Hi, describeSubject as Hn, lifecycleItemLabel as Hr, branchOf as Ht, settleQueue as I, shortNameOf as Ii, holdWorktree as In, workspaceStamps as Ir, QUOTE_LIMITS as It, writeServerRecord as J, agentById as Ji, fetchHead as Jn, queueAddMany as Jr, eventName as Jt, stopServer as K, STORE_ROOT as Ki, followedBranch as Kn, sectionOf as Kr, createPrState as Kt, readIndexToken as L, shorthandOf as Li, isWorktreeHeld as Ln, LIFECYCLES as Lr, REPOSITORY_SCOPE as Lt, runBatch as M, DetachedHeadError as Mi, isBusy as Mn, reviewedRepositories as Mr, storeArchitecture as Mt, activateQueued as N, isCommitReview as Ni, withReviewLock as Nn, summarise as Nr, storedScopes as Nt, batchIdentity as O, WITHHELD_REASON as Oi, escapeHtml as On, presentedKissRunOf as Or, loadArchitecture as Ot, compactQueue as P, resolveBase as Pi, MANAGED_ROOT as Pn, touchWorkspace as Pr, trackedFiles as Pt, parseWorkspacePort as Q, AgentCancelledError as Qi, allJobs as Qn, doneVerb as Qr, markReadForReview as Qt, bootstrapUrl as R, git as Ri, readMarker as Rn, activityAttributes as Rr, ancestorsOf as Rt, beginRun as S, wasReassessed as Si, authorPreview as Sn, latestKissRun as Sr, recordRuntimeBestEffort as St, reportRun as T, extractJsonText as Ti, renderMarkdown as Tn, matchingWorkspaceIds as Tr, formatDuration as Tt, serverEntryExists as U, readAgentPreference as Ui, findExistingReview as Un, lifecyclePill as Ur, subjectOf as Ut, readServerRecord as V, resolveModel as Vi, describeMovement as Vn, lifecycleChip as Vr, announceJob as Vt, spawnServer as W, writeAgentPreference as Wi, checkCodeFreshness as Wn, offersLifecycle as Wr, categoryOf as Wt, MAX_WORKSPACE_PORT as X, detectAgents as Xi, fillFileUrlTemplate as Xn, queueStamp as Xr, markAllRead as Xt, DEFAULT_WORKSPACE_PORT as Y, agentFor as Yi, forgeResolver as Yn, queueRemove as Yr, listNotifications as Yt, MIN_WORKSPACE_PORT as Z, isAgentId as Zi, RUN_RECORD_RETENTION_MS as Zn, readQueue as Zr, markRead as Zt, recordKissRun as _, gradeScale as _i, isUnchanged as _n, groupByLineage as _r, BUILD_VERSION as _t, wasBlocked as a, processDiscussion as ai, toneOf as an, saveJob as ar, settleReview as at, track as b, reassessIn as bi, chatConversationSection as bn, kissDecisionRefusal as br, waitingHistory as bt, RefreshUnavailableError as c, explanationOf as ci, withUsageRecorded as cn, isTerminalJobPhase as cr, startJob as ct, mergeUpdateInto as d, ANALYSIS_ONLY as di, adoptLegacyBaseline as dn, purposeOf as dr, record as dt, isQuestionOutstanding as ei, pruneNotifications as en, endedAt as er, statedWorkspacePort as et, updateReview as f, analysisRefOf as fi, prepareConversationWithin as fn, clearClaim as fr, stamp as ft, runningJobs as g, findingIn as gi, refreshPrContext as gn, followedRefName as gr, telemetryUploadEnabled as gt, liveUpdateFor as h, findingAssessment as hi, unforeseenFailure as hn, decideKissRecommendation as hr, telemetryUploadConsent as ht, liveJobsFor as i, reviewerDispositions as ii, retireNotification as in, pruneJobResidue as ir, prepareReview as it, unsupportedReason as j, originRemote as ji, ReviewBeingDeletedError as jn, reviewedCommit as jr, repositoryKey as jt, extractPullRequestLinks as k, kissOutcome as ki, renderInlineText as kn, previousKissResult as kr, partFiles as kt, cancelUpdate as l, isExplanationAction as li, existingPaths as ln, phasesFor as lr, append as lt, UpdateAlreadyRunningError as m, currentGradeIn as mi, storedConversation as mn, CLAIM_LEASE_MS as mr, recordTelemetryUploadConsent as mt, deleteReview as n, questionsOutstanding as ni, readStateOf as nn, isTerminal as nr, failureOf as nt, discardJob as o, recommendationsOf as oi, unreadAround as on, PHASES as or, reanalyseReview as ot, askCheckout as p, assessmentLookup as pi, saveRefresh as pn, isClaimed as pr, deliver as pt, workspaceUrl as q, AGENT_IDS as qi, reviewedShaOf as qn, queueAdd as qr, createPrUpdate as qt, lineageIds as r, resolvedByConversation as ri, removeNotification as rn, loadJob as rr, observeReview as rt, FETCH_TIMEOUT_MS as s, explanationActionOf as si, unreadCount as sn, isLiveJob as sr, runJob as st, deleteLineage as t, issuesOutstanding as ti, readNotification as tn, fail as tr, writePortPreference as tt, cancellableHere as u, withExplanation as ui, ASK_REREAD_CEILING_MS as un, progressSteps as ur, readEvents as ut, startKissJob as v, isGradeFor as vi, agentEnvOf as vn, indexKeptIds as vr, PACKAGE_NAME as vt, endRun as w, asString$1 as wi, GITHUB_MARKDOWN as wn, loadWorkspace as wr, describeRuntimes as wt, RUN_KINDS as x, sourcePhrase as xi, UNESTABLISHED_MOTIVATION as xn, kissDecisionsOf as xr, mapRuntimes as xt, isDriving as y, promptGrade as yi, EXPLAIN_SIMPLY_PROMPT as yn, indexReviewGroups as yr, feedbackUrl as yt, clearServerRecordOwnedBy as z, refExists as zi, removeWorktree as zn, activityLabel as zr, isArchitectureId as zt };