prreviewbuddy 0.79.0 → 0.80.3

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.
@@ -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));
@@ -15315,7 +15535,17 @@ function aboutMinutes(ms) {
15315
15535
  }
15316
15536
  //#endregion
15317
15537
  //#region ../../packages/review-harness/src/runtime/store.ts
15318
- var VERSION = 1;
15538
+ var VERSION = 2;
15539
+ /**
15540
+ * Version 1 is the same shape with one field that cannot be trusted. Every update row it holds
15541
+ * names the default agent, Claude, whichever agent ran it: the update's job was stamped with the
15542
+ * daemon's default rather than the review's own, and the row was copied from the job. So that
15543
+ * field is a constant on every such row, and reading it as a fact would credit Codex's updates to
15544
+ * Claude for as long as the rows are kept, which is indefinitely. It is dropped rather than
15545
+ * rewritten, because which agent did run is not recorded anywhere this file can see. The duration
15546
+ * was always right and stays, and so does every other operation's agent.
15547
+ */
15548
+ var VERSION_WITH_UNKNOWN_UPDATE_AGENTS = 1;
15319
15549
  /** Kept in all, so a machine with years of reviews holds a bounded file. */
15320
15550
  var ROWS_KEPT = 1e3;
15321
15551
  var LOCK = "runtimes";
@@ -15367,12 +15597,19 @@ function keyOf$1(row) {
15367
15597
  function readRows() {
15368
15598
  try {
15369
15599
  const parsed = JSON.parse(readFileSync(runtimesPath(), "utf8"));
15370
- if (parsed?.version !== VERSION || !Array.isArray(parsed.runs)) return [];
15371
- return parsed.runs.filter(isRow);
15600
+ if (!Array.isArray(parsed?.runs)) return [];
15601
+ if (parsed.version === VERSION) return parsed.runs.filter(isRow);
15602
+ if (parsed.version === VERSION_WITH_UNKNOWN_UPDATE_AGENTS) return parsed.runs.filter(isRow).map(withoutUpdateAgent);
15603
+ return [];
15372
15604
  } catch {
15373
15605
  return [];
15374
15606
  }
15375
15607
  }
15608
+ function withoutUpdateAgent(row) {
15609
+ if (row.operation !== "update") return row;
15610
+ const { agent: _unknown, ...rest } = row;
15611
+ return rest;
15612
+ }
15376
15613
  function isRow(value) {
15377
15614
  const row = value;
15378
15615
  return typeof row?.operation === "string" && typeof row.repository === "string" && typeof row.subject === "string" && typeof row.durationMs === "number" && Number.isFinite(row.durationMs) && row.durationMs >= 0 && typeof row.completedAt === "number";
@@ -15938,6 +16175,7 @@ async function startJob(input) {
15938
16175
  target,
15939
16176
  env: agent.captureEnv(),
15940
16177
  purpose: "review",
16178
+ ...input.source ? { source: input.source } : {},
15941
16179
  ...input.requestedModel ? { requestedModel: input.requestedModel } : {},
15942
16180
  ...review.type === "kiss" ? {
15943
16181
  reviewType: "kiss",
@@ -15971,7 +16209,7 @@ async function startJob(input) {
15971
16209
  * The previous result is snapshotted before the session is replaced, so the review keeps saying who
15972
16210
  * made the analysis it is retiring as well as who made the one it now shows.
15973
16211
  */
15974
- async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel) {
16212
+ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel, source) {
15975
16213
  const workspace = loadWorkspace(workspaceId);
15976
16214
  if (!workspace) throw new Error("No such review.");
15977
16215
  const sha = workspace.session.metadata?.commitSha;
@@ -16003,6 +16241,7 @@ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel)
16003
16241
  env: agent.captureEnv(),
16004
16242
  purpose: "review",
16005
16243
  reanalysis: true,
16244
+ ...source ? { source } : {},
16006
16245
  ...model ? { requestedModel: model } : {}
16007
16246
  });
16008
16247
  saveJob(job);
@@ -16062,7 +16301,7 @@ async function runJob(workspaceId, runners = {}, reviewType = "standard", option
16062
16301
  outer?.addEventListener("abort", () => controller.abort(), { once: true });
16063
16302
  const unwatch = watchForStop(started.jobId, controller);
16064
16303
  try {
16065
- return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder, controller.signal);
16304
+ return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder, controller.signal, options.source);
16066
16305
  } finally {
16067
16306
  unwatch();
16068
16307
  clearJobEnding(started.jobId);
@@ -16117,16 +16356,18 @@ function runOfJob(workspace, jobId) {
16117
16356
  if (!run) throw new Error("This KISS job has no run to write its answer to.");
16118
16357
  return run;
16119
16358
  }
16120
- async function drive(jobId, workspaceId, runners, driver, signal) {
16359
+ async function drive(jobId, workspaceId, runners, driver, signal, source) {
16121
16360
  let job = loadJob(jobId);
16122
16361
  if (!job) throw new Error("No such job.");
16123
16362
  const now = runners.now ?? Date.now;
16124
16363
  const startedAt = now();
16125
- const from = resumeFrom(job);
16126
- job = {
16127
- ...job,
16128
- startedAt
16129
- };
16364
+ const from = checkoutGone$1(job) ? "preparing" : resumeFrom(job);
16365
+ job = beginAttempt(job, {
16366
+ startedAt,
16367
+ phase: from,
16368
+ ...source ? { source } : {}
16369
+ });
16370
+ saveJob(job);
16130
16371
  try {
16131
16372
  signal?.throwIfAborted();
16132
16373
  if (needs(from, "preparing")) {
@@ -16356,6 +16597,18 @@ function explain(job, detail) {
16356
16597
  }
16357
16598
  }
16358
16599
  /** True when this phase is at or after the point the job resumes from. */
16600
+ /**
16601
+ * The record says work was done in a checkout that is no longer there.
16602
+ *
16603
+ * The sweep removes a finished job's checkout after six hours and keeps its record for seven days,
16604
+ * so a Retry of an older failure finds `preparing` complete and nothing on disk. Starting from
16605
+ * `preparing` makes the checkout again at the job's commit, and everything after it with it, because
16606
+ * every later phase reads files from it.
16607
+ */
16608
+ function checkoutGone$1(job) {
16609
+ if (!job.completed.includes("preparing")) return false;
16610
+ return !job.worktreePath || !existsSync(join(job.worktreePath, ".prreviewbuddy-worktree.json"));
16611
+ }
16359
16612
  function needs(from, phase) {
16360
16613
  return WORK_PHASES.indexOf(phase) >= WORK_PHASES.indexOf(from);
16361
16614
  }
@@ -16642,7 +16895,7 @@ function failureOf(workspaceId) {
16642
16895
  * "whatever the default is", so a configured default of Codex announced Codex and then analysed
16643
16896
  * with Claude, and nothing said so until the failure named the wrong tool.
16644
16897
  */
16645
- async function prepareReview(target, agentId, fresh, requestedModel) {
16898
+ async function prepareReview(target, agentId, fresh, requestedModel, source) {
16646
16899
  if (!fresh) {
16647
16900
  const existing = await findExistingReview(target);
16648
16901
  if (existing) {
@@ -16662,7 +16915,8 @@ async function prepareReview(target, agentId, fresh, requestedModel) {
16662
16915
  workspaceId: await startJob({
16663
16916
  target,
16664
16917
  agentId,
16665
- ...requestedModel ? { requestedModel } : {}
16918
+ ...requestedModel ? { requestedModel } : {},
16919
+ ...source ? { source } : {}
16666
16920
  })
16667
16921
  };
16668
16922
  }
@@ -16679,10 +16933,13 @@ async function prepareReview(target, agentId, fresh, requestedModel) {
16679
16933
  * that reads as a clean bill of health, which is the opposite of what happened. A KISS run's
16680
16934
  * answer is checked by `drive`, which knows which run it is.
16681
16935
  */
16682
- async function settleReview(workspaceId, reviewType = "standard", signal) {
16936
+ async function settleReview(workspaceId, reviewType = "standard", signal, source) {
16683
16937
  let job;
16684
16938
  try {
16685
- job = await runJob(workspaceId, {}, reviewType, signal ? { signal } : {});
16939
+ job = await runJob(workspaceId, {}, reviewType, {
16940
+ ...signal ? { signal } : {},
16941
+ ...source ? { source } : {}
16942
+ });
16686
16943
  } catch (error) {
16687
16944
  return {
16688
16945
  ok: false,
@@ -17645,6 +17902,7 @@ async function recordKissRun(input) {
17645
17902
  purpose: "review",
17646
17903
  reviewType: "kiss",
17647
17904
  audience: input.audience,
17905
+ ...input.source ? { source: input.source } : {},
17648
17906
  ...requestedModel ? { requestedModel } : {}
17649
17907
  });
17650
17908
  saveJob(job);
@@ -18440,7 +18698,7 @@ async function claimCheckoutJob(workspace, sha, fields) {
18440
18698
  const job = createJob({
18441
18699
  workspaceId: workspace.id,
18442
18700
  target,
18443
- env: captureEnv(),
18701
+ env: agentEnvOf(workspace),
18444
18702
  ...fields
18445
18703
  });
18446
18704
  saveJob(job);
@@ -18608,6 +18866,7 @@ async function updateReview(workspace, runners = {}) {
18608
18866
  if (workspace.changeSet.targetType === "commit") return refuse(reviewedSha, false, COMMIT_REVIEW_CANNOT_UPDATE);
18609
18867
  const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
18610
18868
  purpose: "update",
18869
+ ...runners.source ? { source: runners.source } : {},
18611
18870
  ...workspace.prContext ? { conversation: {
18612
18871
  kind: "attached",
18613
18872
  context: workspace.prContext
@@ -18673,7 +18932,7 @@ function cancelled(held, reviewedSha) {
18673
18932
  };
18674
18933
  }
18675
18934
  async function bringForward(workspace, runners, reviewedSha, held, step, signal, clock) {
18676
- const remote = await remoteHead(workspace, signal, runners);
18935
+ const remote = await remoteHead(workspace, held.job.env, signal, runners);
18677
18936
  signal.throwIfAborted();
18678
18937
  if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, remote.refusal ?? noRemoteHeadMessage(workspace));
18679
18938
  adoptLegacyBaseline(workspace);
@@ -18696,9 +18955,10 @@ async function bringForward(workspace, runners, reviewedSha, held, step, signal,
18696
18955
  return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the latest commit could not be prepared, so nothing was reassessed.");
18697
18956
  }
18698
18957
  signal.throwIfAborted();
18699
- return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal, clock, prepared, discussionMoved);
18958
+ return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job, signal, clock, prepared, discussionMoved);
18700
18959
  }
18701
- async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal, clock, prepared, discussionMoved) {
18960
+ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, job, signal, clock, prepared, discussionMoved) {
18961
+ const { id: jobId, env } = job;
18702
18962
  const newHead = remote.sha;
18703
18963
  let delta = null;
18704
18964
  if (reviewedSha !== newHead) try {
@@ -18733,9 +18993,9 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
18733
18993
  let raw;
18734
18994
  try {
18735
18995
  const prompt = buildUpdatePrompt(workspace, delta, prepared, reviewedSha, newHead, worktreePath);
18736
- raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace), onProgress, onObservedModel, signal) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace), onProgress, requestedModel, onObservedModel, signal);
18996
+ raw = runners.run ? await runners.run(prompt, worktreePath, env, onProgress, onObservedModel, signal) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, env, onProgress, requestedModel, onObservedModel, signal);
18737
18997
  } catch (error) {
18738
- return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", explainUpdateFailure(error, agentEnvOf(workspace), requestedModel));
18998
+ return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", explainUpdateFailure(error, env, requestedModel));
18739
18999
  }
18740
19000
  const parsed = parseReconciliationResult(raw, previous.issues.length, { priorQuestionCount: previous.questions.length });
18741
19001
  if (!parsed) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", "The reassessment came back in a form this could not read, so nothing was changed.");
@@ -18753,7 +19013,7 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
18753
19013
  const gradeChanges = await applyUpdate(workspace, result, previous, newHead, worktreePath, {
18754
19014
  ...requestedModel ? { requestedModel } : {},
18755
19015
  ...observedModel ? { observedModel } : {},
18756
- ...await (runners.agentVersion ?? installedAgentVersion)(agentEnvOf(workspace))
19016
+ ...await (runners.agentVersion ?? installedAgentVersion)(env)
18757
19017
  }, clock, remote.base, suggestions, conversationTakenIntoAccount(prepared, analysedBaselineOf(workspace)));
18758
19018
  const counts = {
18759
19019
  ...countUpdateChanges(previous, result),
@@ -18787,9 +19047,9 @@ var BASELINE_GONE_MESSAGE = "The commit this review was made from is no longer i
18787
19047
  * is allowed to reach the network and that panel is not, so it looks at a newer copy of the same
18788
19048
  * refs.
18789
19049
  */
18790
- async function remoteHead(workspace, signal, runners) {
19050
+ async function remoteHead(workspace, env, signal, runners) {
18791
19051
  const source = await storedRequestSource(workspace);
18792
- if (source) return requestHead(workspace, source, signal, runners);
19052
+ if (source) return requestHead(workspace, env, source, signal, runners);
18793
19053
  const fetchFailed = !await fetchOrigin(workspace.repoPath, signal);
18794
19054
  return {
18795
19055
  sha: (await followedBranch(workspace))?.sha ?? null,
@@ -18814,8 +19074,8 @@ async function remoteHead(workspace, signal, runners) {
18814
19074
  * rather than a guess. If even that fetch fails, the last head this repository saw is used and
18815
19075
  * the update says the fetch failed, as it always has.
18816
19076
  */
18817
- async function requestHead(workspace, source, signal, runners) {
18818
- const outcome = await (runners.headOf ?? ((cwd, asked) => fetchHeadFor(cwd, asked, agentEnvOf(workspace))))(workspace.repoPath, source);
19077
+ async function requestHead(workspace, env, source, signal, runners) {
19078
+ const outcome = await (runners.headOf ?? ((cwd, asked) => fetchHeadFor(cwd, asked, env)))(workspace.repoPath, source);
18819
19079
  signal.throwIfAborted();
18820
19080
  const baseBranch = workspace.changeSet.baseRef || void 0;
18821
19081
  if (outcome.kind === "head") {
@@ -19298,7 +19558,7 @@ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgre
19298
19558
  * nothing is running has answered the same question better, and passes 0.
19299
19559
  */
19300
19560
  async function discardJob(job, options = {}) {
19301
- if (job.worktreePath) {
19561
+ if (job.worktreePath && !checkoutGone(job.worktreePath)) {
19302
19562
  const outcome = await removeWorktree({
19303
19563
  originRepoPath: job.target.originRepoPath,
19304
19564
  worktreePath: job.worktreePath,
@@ -19313,6 +19573,18 @@ async function discardJob(job, options = {}) {
19313
19573
  clearClaim(job.id);
19314
19574
  return { removed: true };
19315
19575
  }
19576
+ /**
19577
+ * Only when the filesystem says there is nothing there. Any other failure to look, a permission
19578
+ * error say, is not proof of absence, and the record is the only thing that names the directory.
19579
+ */
19580
+ function checkoutGone(path) {
19581
+ try {
19582
+ lstatSync(path);
19583
+ return false;
19584
+ } catch (error) {
19585
+ return error.code === "ENOENT";
19586
+ }
19587
+ }
19316
19588
  //#endregion
19317
19589
  //#region ../../packages/review-harness/src/workspace/delete_review.ts
19318
19590
  /**
@@ -19396,13 +19668,33 @@ async function deleteReview(id) {
19396
19668
  *
19397
19669
  * The record is unlinked **whether or not** a checkout was refused, and that is deliberate. The
19398
19670
  * 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
19671
+ * comes back to it once the checkout is past retention, with the origin repository still recorded, and the refusal is
19400
19672
  * reported so nobody has to discover it later.
19401
19673
  */
19402
19674
  async function deleteLocked(id) {
19403
19675
  const report = emptyReport();
19404
19676
  if (!loadWorkspace(id)) return report;
19405
19677
  for (const job of allJobs().filter((job) => job.workspaceId === id)) {
19678
+ if (isTerminal(job) && purposeOf(job) !== "ask") {
19679
+ if (job.worktreePath && existsSync(job.worktreePath)) {
19680
+ const outcome = await removeWorktree({
19681
+ originRepoPath: job.target.originRepoPath,
19682
+ worktreePath: job.worktreePath,
19683
+ minAgeMs: 0
19684
+ });
19685
+ if (!outcome.removed) {
19686
+ report.refusals.push(outcome.message);
19687
+ saveJob({
19688
+ ...strippedForDeletedReview(job),
19689
+ worktreePath: job.worktreePath
19690
+ });
19691
+ continue;
19692
+ }
19693
+ report.worktreesRemoved += 1;
19694
+ }
19695
+ saveJob(strippedForDeletedReview(job));
19696
+ continue;
19697
+ }
19406
19698
  const outcome = await discardJob(job, { minAgeMs: 0 });
19407
19699
  if (outcome.refusal) {
19408
19700
  report.refusals.push(outcome.refusal);
@@ -19461,4 +19753,4 @@ function lineageIds(id) {
19461
19753
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
19462
19754
  }
19463
19755
  //#endregion
19464
- export { resolveWorkspacePort as $, saveJob as $n, recommendationsOf as $r, pruneNotifications as $t, parsePullRequestLink as A, shorthandOf as Ai, ReviewBeingDeletedError as An, LIFECYCLES as Ar, repositoryKey as At, ensureServer as B, AGENT_IDS as Bi, findExistingReview as Bn, queueAdd as Br, announceJob as Bt, completeRun as C, resolveTarget as Ci, GITHUB_MARKDOWN as Cn, recentWorkspaces as Cr, RUNTIME_WORDS as Ct, relativeTime as D, resolveBase as Di, escapeHtml as Dn, touchWorkspace as Dr, loadArchitecture as Dt, stopRun as E, isCommitReview as Ei, CODE_COPY_CLASS as En, summarise as Er, checkArchitecture as Et, queueForReview as F, clearAgentPreference as Fi, readMarker as Fn, lifecycleItemLabel as Fr, QUOTE_LIMITS as Ft, stopChild as G, AgentUnavailableError as Gi, fetchHead as Gn, doneVerb as Gr, createPrState as Gt, reviewsUrl as H, agentFor as Hi, checkReview as Hn, queueRemove as Hr, subjectOf as Ht, settleQueue as I, readAgentPreference as Ii, removeWorktree as In, lifecyclePill as Ir, REPOSITORY_SCOPE as It, writeServerRecord as J, allJobs as Jn, issuesOutstanding as Jr, listNotifications as Jt, stopServer as K, forgeResolver as Kn, isIssueOutstanding as Kr, createPrUpdate as Kt, readIndexToken as L, writeAgentPreference as Li, requestJobStop as Ln, offersLifecycle as Lr, ancestorsOf as Lt, runBatch as M, refExists as Mi, createWorktree as Mn, activityLabel as Mr, storedScopes as Mt, activateQueued as N, resolveAgent as Ni, holdWorktree as Nn, isLifecycle as Nr, trackedFiles as Nt, batchIdentity as O, AmbiguousRefError as Oi, renderInlineText as On, workspaceRevision as Or, partFiles as Ot, compactQueue as P, resolveModel as Pi, isWorktreeHeld as Pn, lifecycleChip as Pr, ownersOf as Pt, parseWorkspacePort as Q, loadJob as Qn, processDiscussion as Qr, moveIds as Qt, bootstrapUrl as R, CONFIG_PATH as Ri, describeMovement as Rn, parseLifecycle as Rr, isArchitectureId as Rt, beginRun as S, kissOutcome as Si, hasAuthorContent as Sn, previousKissResult as Sr, recordRuntimeBestEffort as St, reportRun as T, DetachedHeadError as Ti, CODE_BLOCK_CLASS as Tn, reviewedRepositories as Tr, ARCHITECTURE_DIR as Tt, serverEntryExists as U, detectAgents as Ui, followedBranch as Un, queueStamp as Ur, categoryOf as Ut, readServerRecord as V, agentById as Vi, checkCodeFreshness as Vn, queueAddMany as Vr, branchOf as Vt, spawnServer as W, AgentCancelledError as Wi, reviewedShaOf as Wn, readQueue as Wr, createArchitectureNotification as Wt, MAX_WORKSPACE_PORT as X, fail as Xn, resolvedByConversation as Xr, markRead as Xt, DEFAULT_WORKSPACE_PORT as Y, endedAt as Yn, questionsOutstanding as Yr, markAllRead as Yt, MIN_WORKSPACE_PORT as Z, isTerminal as Zn, reviewerDispositions as Zr, markReadForReview as Zt, recordKissRun as _, asString$1 as _i, agentEnvOf as _n, loadWorkspace as _r, BUILD_VERSION as _t, wasBlocked as a, analysisRefOf as ai, unreadAround as an, purposeOf as ar, settleReview as at, track as b, describeAuthorship as bi, UNESTABLISHED_MOTIVATION as bn, positionInLineage as br, waitingHistory as bt, RefreshUnavailableError as c, findingAssessment as ci, existingPaths as cn, decideKissRecommendation as cr, startJob as ct, mergeUpdateInto as d, isGradeFor as di, prepareConversationWithin as dn, indexKeptIds as dr, record as dt, explanationActionOf as ei, readNotification as en, PHASES as er, statedWorkspacePort as et, updateReview as f, promptGrade as fi, saveRefresh as fn, indexReviewGroups as fr, stamp as ft, runningJobs as g, followUpFindingId as gi, isUnchanged as gn, lineagePosition as gr, telemetryUploadEnabled as gt, liveUpdateFor as h, wasReassessed as hi, refreshPrContext as hn, latestKissRun as hr, telemetryUploadConsent as ht, liveJobsFor as i, ANALYSIS_ONLY as ii, toneOf as in, progressSteps as ir, prepareReview as it, unsupportedReason as j, git as ji, MANAGED_ROOT as jn, activityAttributes as jr, storeArchitecture as jt, extractPullRequestLinks as k, shortNameOf as ki, modelHelpLines as kn, workspaceStamps as kr, readArchitecture as kt, cancelUpdate as l, findingIn as li, ASK_REREAD_CEILING_MS as ln, followedRefName as lr, append as lt, UpdateAlreadyRunningError as m, sourcePhrase as mi, unforeseenFailure as mn, kissDecisionsOf as mr, recordTelemetryUploadConsent as mt, deleteReview as n, isExplanationAction as ni, removeNotification as nn, isTerminalJobPhase as nr, failureOf as nt, discardJob as o, assessmentLookup as oi, unreadCount as on, clearClaim as or, reanalyseReview as ot, askCheckout as p, reassessIn as pi, storedConversation as pn, kissDecisionRefusal as pr, deliver as pt, workspaceUrl as q, fillFileUrlTemplate as qn, isQuestionOutstanding as qr, eventName as qt, lineageIds as r, withExplanation as ri, retireNotification as rn, phasesFor as rr, observeReview as rt, FETCH_TIMEOUT_MS as s, currentGradeIn as si, withUsageRecorded as sn, isClaimed as sr, runJob as st, deleteLineage as t, explanationOf as ti, readStateOf as tn, isLiveJob as tr, writePortPreference as tt, cancellableHere as u, gradeScale as ui, adoptLegacyBaseline as un, groupByLineage as ur, readEvents as ut, startKissJob as v, extractJsonText as vi, EXPLAIN_SIMPLY_PROMPT as vn, matchingWorkspaceIds as vr, PACKAGE_NAME as vt, endRun as w, originRemote as wi, renderMarkdown as wn, reviewedCommit as wr, describeRuntimes as wt, RUN_KINDS as x, WITHHELD_REASON as xi, authorPreview as xn, presentedKissRunOf as xr, mapRuntimes as xt, isDriving as y, objectWithAnyOf as yi, chatConversationSection as yn, mutateWorkspace as yr, feedbackUrl as yt, clearServerRecordOwnedBy as z, STORE_ROOT as zi, describeSubject as zn, sectionOf as zr, writeJsonAtomically as zt };
19756
+ 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 };