@fullstackhouse/cerber 0.24.0 → 0.25.0

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/README.md CHANGED
@@ -384,6 +384,13 @@ shadow mode for a while: every would-send decision lands in
384
384
  verdicts and comments survive your review — enable auto-send when its 90%
385
385
  actually means 90%.
386
386
 
387
+ ## How it works
388
+
389
+ [**docs/lifecycle.md**](docs/lifecycle.md) is the reference behind everything
390
+ above: the seven statuses and what sets each, which tab a PR lands in and why,
391
+ when the poll files or archives a row on its own, when a re-review happens and
392
+ what it will not overwrite, and what gets written to disk at each step.
393
+
387
394
  ## Development
388
395
 
389
396
  ```bash
package/dist/cli/index.js CHANGED
@@ -8,8 +8,9 @@ import { configPath, loadConfig, saveConfig } from "../core/config.js";
8
8
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
9
9
  import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
10
10
  import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
11
- import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
11
+ import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
12
12
  import { pool, reviewPr } from "../runner/review.js";
13
+ import { isReviewRunning } from "../runner/inflight.js";
13
14
  import { startDaemon } from "../server/daemon.js";
14
15
  import { startServer } from "../server/index.js";
15
16
  const program = new Command();
@@ -135,6 +136,15 @@ program
135
136
  console.error(`Already sent at ${artifact.sent.at}${artifact.sent.url ? ` — ${artifact.sent.url}` : ""}`);
136
137
  process.exit(1);
137
138
  }
139
+ // The persisted status is the only signal here: a run started by `serve` or
140
+ // by another terminal is invisible to this process. Sending mid-run vouches
141
+ // for a draft that is being replaced as you read it.
142
+ if (artifact.status === "running") {
143
+ console.error(`A review of ${id} is running — wait for it to finish, then send. ` +
144
+ `If nothing is actually running, the last run died mid-flight: re-run it, or restart \`cerber serve\`, ` +
145
+ `either of which clears the stale status.`);
146
+ process.exit(1);
147
+ }
138
148
  const event = (opts.event ??
139
149
  (artifact.verdict ? eventForRecommendation(artifact.verdict.recommendation) : "COMMENT")).toUpperCase();
140
150
  if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(event)) {
@@ -352,6 +362,13 @@ program
352
362
  `matching PRs, with nobody watching. Pass --no-trust to keep every review read-only, ` +
353
363
  `or --no-auto-review to only review on click.`);
354
364
  }
365
+ // Before anything polls. Reconciliation is what makes "nothing is running"
366
+ // true after an unclean shutdown, and the daemon's first tick fires the
367
+ // moment it is constructed — so starting it first put both of them on the
368
+ // same leftover artifact with no ordering between them.
369
+ const cleared = await reconcileRunning({ inUse: isReviewRunning });
370
+ if (cleared > 0)
371
+ console.log(`Cleared ${cleared} interrupted run(s) left by a previous process.`);
355
372
  const daemon = opts.poll
356
373
  ? startDaemon({
357
374
  repos: opts.repo.length > 0 ? opts.repo : d.repos,
@@ -97,6 +97,17 @@ export const RunInfoSchema = z.object({
97
97
  * be filed away automatically and one that must not be.
98
98
  */
99
99
  trigger: z.enum(["daemon", "user"]).nullable().default(null),
100
+ /**
101
+ * The head commit this run actually read, set when it finishes.
102
+ *
103
+ * `pr.headSha` cannot answer that question: a refresh moves it forward to
104
+ * keep the comments anchored to current code, without anybody re-reading
105
+ * anything. The freshness guard compares against *this* instead, so merely
106
+ * opening a draft after a push no longer convinces the poll that the draft
107
+ * is up to date. Null on runs recorded before this existed, and on one still
108
+ * in flight — both fall back to the old comparison.
109
+ */
110
+ reviewedSha: z.string().nullable().default(null),
100
111
  });
101
112
  export const SentInfoSchema = z.object({
102
113
  at: z.string(),
@@ -267,6 +278,18 @@ export const ArtifactSchema = z.object({
267
278
  sent: SentInfoSchema.nullable().default(null),
268
279
  /** Set when cerber filed this draft away itself, and why. Never on a sent one. */
269
280
  filed: FiledInfoSchema.nullable().default(null),
281
+ /**
282
+ * When this review was settled — marked `reviewed` or `skipped`, by you or by
283
+ * cerber filing it. Null on anything not settled.
284
+ *
285
+ * A settle answers the request that was open at the time. It cannot answer
286
+ * one that came afterwards, so the poll needs to know which side of it a
287
+ * review request falls on before deciding a settled row stays settled
288
+ * (`askedAgainAfterSettling` in `src/server/daemon.ts`). `updatedAt` cannot
289
+ * stand in for this: opening a settled review refreshes it, which moves that
290
+ * field forward long after the decision it is meant to date.
291
+ */
292
+ settledAt: z.string().nullable().default(null),
270
293
  /** Last time this review was pulled forward onto a newer head commit. */
271
294
  refresh: RefreshInfoSchema.nullable().default(null),
272
295
  calibration: CalibrationSchema.nullable().default(null),
package/dist/core/gh.js CHANGED
@@ -254,6 +254,58 @@ export async function fetchReviewRequests(ref) {
254
254
  export function stillRequested(requests, login) {
255
255
  return requests.teams.length > 0 || requests.users.includes(login);
256
256
  }
257
+ export function lastRequestOf(events, login) {
258
+ const mine = events.filter((e) => e.requestedReviewer?.__typename === "User" && e.requestedReviewer.login === login);
259
+ if (mine.length === 0)
260
+ return null;
261
+ return mine.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)).createdAt;
262
+ }
263
+ // 100 is the page maximum, and it costs exactly what a smaller window would:
264
+ // one call. It wants to be big because the recent events are not the useful
265
+ // ones — a PR that cycled through a dozen reviewers can push the request that
266
+ // named *you* past a short window, and the answer would come back "nobody
267
+ // asked", silently restoring the bug this exists to fix.
268
+ //
269
+ // It is still a window, not a guarantee: a PR carrying more than 100 review
270
+ // requests would lose the oldest, and that is accepted rather than paginated.
271
+ // Paginating would cost one call per extra page on every settled row on every
272
+ // poll, to cover a PR that does not realistically exist — and the failure mode
273
+ // is the conservative one, a row left settled rather than one wrongly reopened.
274
+ const LAST_REQUEST_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
275
+ repository(owner:$owner,name:$repo){
276
+ pullRequest(number:$number){
277
+ timelineItems(last:100,itemTypes:[REVIEW_REQUESTED_EVENT]){
278
+ nodes{... on ReviewRequestedEvent{createdAt requestedReviewer{__typename ... on User{login}}}}
279
+ }
280
+ }
281
+ }
282
+ }`;
283
+ /**
284
+ * Ask GitHub when your review was last requested on a PR.
285
+ *
286
+ * GraphQL rather than the REST timeline on purpose: this runs on rows the queue
287
+ * already holds, poll after poll, and the REST timeline of a busy PR is hundreds
288
+ * of events across several pages — every one of them fetched to find the handful
289
+ * that are review requests. Filtering server-side to the one event type turns
290
+ * that into a single call; the window it reads is bounded, as the query above
291
+ * explains.
292
+ */
293
+ export async function fetchLastReviewRequest(ref, login) {
294
+ const out = await gh([
295
+ "api",
296
+ "graphql",
297
+ "-f",
298
+ `query=${LAST_REQUEST_QUERY}`,
299
+ "-F",
300
+ `owner=${ref.owner}`,
301
+ "-F",
302
+ `repo=${ref.repo}`,
303
+ "-F",
304
+ `number=${ref.number}`,
305
+ ]);
306
+ const raw = JSON.parse(out);
307
+ return lastRequestOf(raw.data?.repository?.pullRequest?.timelineItems?.nodes ?? [], login);
308
+ }
257
309
  export function searchAwaitingArgs(repoFilter, limit = 50) {
258
310
  const args = [
259
311
  "search",
@@ -34,21 +34,52 @@ export function refreshArtifact(artifact, pr, diff) {
34
34
  };
35
35
  }
36
36
  /**
37
- * Comments a human put work into: their own, and AI ones they rewrote.
38
- * A re-review regenerates everything the AI said, but must not silently bin
39
- * these — see carryOverComments.
37
+ * Is this artifact's status the user's to keep, rather than the run's to set?
38
+ *
39
+ * A send or a settle is a decision about the PR; a run finishing is a fact
40
+ * about the code. Whichever way the run went — a draft or an error — it does
41
+ * not get to reopen one, which is the same rule `SETTLED_BY_YOU` applies to
42
+ * starting a run in the first place.
40
43
  */
41
- export function humanComments(artifact) {
42
- return artifact.comments.filter((c) => c.origin === "user" || c.editedByUser);
44
+ export function userOwnsStatus(a) {
45
+ return a.sent !== null || a.status === "reviewed" || a.status === "skipped";
43
46
  }
44
47
  /**
45
- * Carry a previous review's human comments into a fresh one, re-anchored to
46
- * the new diff. AI comments are dropped: the new run just regenerated them.
48
+ * Fold a finished review run onto whatever the artifact says now.
49
+ *
50
+ * A run takes minutes and the cockpit stays live throughout, so by the time one
51
+ * lands the artifact may have moved under it. Saving the result wholesale
52
+ * silently undid whatever had happened in between — the same mistake
53
+ * `mergeConcurrentEdits` exists to stop a chat turn making.
54
+ *
55
+ * `fresh` is what the run produced; `current` is what is on disk now. The run
56
+ * owns the draft — summary, chapters, verdict, comments — and that includes
57
+ * replacing comments the user wrote: a re-review starts fresh by design, and
58
+ * `docs/lifecycle.md` says so plainly rather than leaving anyone to count on
59
+ * work surviving one. What it does *not* own are the decisions:
60
+ *
61
+ * - **a send** stands, and takes the status with it. Without this a run
62
+ * finishing after a send wrote `sent: null` back over the record, and the
63
+ * "already sent" guard would then wave a second submission through.
64
+ * - **a settle** stands too: `reviewed` and `skipped` are decisions about the
65
+ * PR, not facts about the code, so a run completing does not reopen one.
66
+ * The fresh draft still lands underneath, which is what the row shows if
67
+ * the user changes their mind.
68
+ * - **the conversation** is the user's writing, snapshot and all.
47
69
  */
48
- export function carryOverComments(previous, fresh) {
49
- const human = humanComments(previous);
50
- if (human.length === 0)
51
- return { comments: fresh.comments, carried: 0, drifted: 0 };
52
- const { comments, drifted } = reanchorComments(human, previous.diff, fresh.diff);
53
- return { comments: [...fresh.comments, ...comments], carried: comments.length, drifted };
70
+ export function mergeRunResult(fresh, current) {
71
+ return {
72
+ ...fresh,
73
+ status: userOwnsStatus(current) ? current.status : fresh.status,
74
+ // Travels with the status it dates. A settle that landed while the run
75
+ // worked keeps both halves of itself, and a run that reopens the row takes
76
+ // the stamp away with the status.
77
+ settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
78
+ sent: current.sent,
79
+ calibration: current.calibration,
80
+ filed: current.filed,
81
+ chat: current.chat,
82
+ preChat: current.preChat,
83
+ pendingChat: current.pendingChat,
84
+ };
54
85
  }
@@ -98,11 +98,21 @@ export async function updateArtifactByKey(key, mutate) {
98
98
  * this it stays wedged forever, with no way to retry it from the cockpit. A
99
99
  * `cerber review` running in another terminal is the one false positive; it
100
100
  * overwrites the artifact when it finishes anyway.
101
+ *
102
+ * `inUse` names the runs this process genuinely owns, and they are left alone.
103
+ * Callers should still reconcile before starting anything that polls — but
104
+ * ordering alone is a promise about wiring, and this is the guard that holds
105
+ * whatever order they are started in: without it, reconciliation racing a
106
+ * daemon's first tick could stamp "interrupted" over a review that had just
107
+ * legitimately begun.
101
108
  */
102
- export async function reconcileRunning() {
109
+ export async function reconcileRunning(opts = {}) {
110
+ const inUse = opts.inUse ?? (() => false);
103
111
  const artifacts = await listArtifacts();
104
112
  let cleared = 0;
105
113
  for (const artifact of artifacts) {
114
+ if (inUse(artifact.id))
115
+ continue;
106
116
  const stuckRun = artifact.status === "running";
107
117
  const stuckChat = artifact.pendingChat && artifact.pendingChat.error == null ? artifact.pendingChat : null;
108
118
  if (!stuckRun && !stuckChat)
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { AiReviewSchema, SCHEMA_VERSION, artifactId, } from "../core/artifact.js";
2
+ import { AiReviewSchema, SCHEMA_VERSION, artifactId, artifactKey, } from "../core/artifact.js";
3
3
  import { createRunDir, evictOldCheckouts, prepareCheckout, removeRunDir } from "../core/checkout.js";
4
4
  import { fetchPrDiff, fetchPrInfo, isOrgMember, isTeamMember } from "../core/gh.js";
5
- import { carryOverComments } from "../core/refresh.js";
6
- import { loadArtifact, saveArtifact } from "../core/state.js";
5
+ import { mergeRunResult, userOwnsStatus } from "../core/refresh.js";
6
+ import { loadArtifact, saveArtifact, updateArtifactByKey } from "../core/state.js";
7
7
  import { loadConfig } from "../core/config.js";
8
8
  import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
9
9
  import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
@@ -32,7 +32,7 @@ const HEAD_SENSITIVE = new Set(["ready", "sent"]);
32
32
  * as cerber overruling you — and on a busy PR it happens every few minutes.
33
33
  * The way back in is the way you got out: press re-review, which forces.
34
34
  */
35
- const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
35
+ export const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
36
36
  /**
37
37
  * Fetch a PR, run the AI review, persist the artifact at each stage.
38
38
  * Everything stays local — nothing is ever written to GitHub.
@@ -91,10 +91,15 @@ async function runReview(ref, opts) {
91
91
  log(`You marked this ${existing.status} — leaving it alone. Use --force to re-review.`);
92
92
  return { artifact: existing, skipped: true };
93
93
  }
94
- if (HEAD_SENSITIVE.has(existing.status) &&
95
- existing.pr.headSha !== "" &&
96
- existing.pr.headSha === pr.headSha) {
97
- log(`Up to date (head ${pr.headSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
94
+ // The sha the AI *read*, not the one the artifact happens to mention.
95
+ // `pr.headSha` is moved forward by the refresh that runs whenever a review
96
+ // is opened, so comparing against it meant that merely looking at a draft
97
+ // after a push convinced this guard the draft was current, and the poll
98
+ // never re-reviewed it again. Older artifacts have no `reviewedSha` and
99
+ // fall back to the old comparison.
100
+ const reviewedSha = existing.run?.reviewedSha ?? existing.pr.headSha;
101
+ if (HEAD_SENSITIVE.has(existing.status) && reviewedSha !== "" && reviewedSha === pr.headSha) {
102
+ log(`Up to date (reviewed at ${reviewedSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
98
103
  return { artifact: existing, skipped: true };
99
104
  }
100
105
  }
@@ -120,6 +125,14 @@ async function runReview(ref, opts) {
120
125
  `reviewing from the diff alone.`);
121
126
  }
122
127
  }
128
+ // A re-review regenerates the draft, comments and all — deliberately, and
129
+ // including any the user wrote. Carrying them across is a feature this tool
130
+ // has decided not to have, so nothing here tries to half-keep them: they go
131
+ // when the run starts, whichever way it ends. `docs/lifecycle.md` says so
132
+ // where a reader would otherwise assume otherwise.
133
+ if (existing && existing.comments.length > 0) {
134
+ log(`Replacing the previous draft's ${existing.comments.length} comment(s) — a re-review starts fresh.`);
135
+ }
123
136
  let artifact = {
124
137
  schemaVersion: SCHEMA_VERSION,
125
138
  id: artifactId(pr),
@@ -142,10 +155,12 @@ async function runReview(ref, opts) {
142
155
  trusted: trusted && source !== null,
143
156
  sessionId: null,
144
157
  trigger: opts.trigger ?? "user",
158
+ reviewedSha: null,
145
159
  },
146
160
  sent: null,
147
161
  refresh: null,
148
162
  filed: null,
163
+ settledAt: null,
149
164
  calibration: null,
150
165
  // The conversation is the user's writing, so a re-review keeps it — the
151
166
  // chat prompt replays the transcript, which is what makes it survive the
@@ -204,34 +219,37 @@ async function runReview(ref, opts) {
204
219
  // whose working directory was empty has nothing a chat turn could read.
205
220
  sessionId: source ? review.sessionId : null,
206
221
  trigger: artifact.run.trigger,
222
+ // What this run actually read — the thing the freshness guard needs
223
+ // and `pr.headSha` cannot say, because a refresh moves that forward
224
+ // without anybody re-reading the code.
225
+ reviewedSha: pr.headSha,
207
226
  },
208
227
  };
209
- // A re-review regenerates the AI's comments, but the human's own comments
210
- // and their rewrites of AI ones are work the run must not throw away.
211
- if (existing) {
212
- const { comments, carried, drifted } = carryOverComments(existing, artifact);
213
- if (carried > 0) {
214
- artifact = { ...artifact, comments };
215
- log(`Carried over ${carried} comment(s) you wrote or edited` +
216
- (drifted > 0 ? `; ${drifted} no longer match the diff and will post in the body.` : "."));
217
- }
218
- }
219
228
  }
220
229
  catch (err) {
221
- artifact = {
222
- ...artifact,
223
- status: "failed",
224
- updatedAt: now(),
225
- run: {
226
- ...artifact.run,
227
- finishedAt: now(),
228
- error: err instanceof Error ? err.message : String(err),
229
- },
230
+ const run = {
231
+ ...artifact.run,
232
+ finishedAt: now(),
233
+ error: err instanceof Error ? err.message : String(err),
230
234
  };
231
- await saveArtifact(artifact);
235
+ // Onto what is on disk, not over it: the artifact this run built holds none
236
+ // of the comments or marks that may have landed while it ran, and a failure
237
+ // is no reason to lose them — nor to reopen a decision the user made.
238
+ const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
239
+ ...a,
240
+ status: userOwnsStatus(a) ? a.status : "failed",
241
+ run,
242
+ }));
243
+ artifact = saved ?? { ...artifact, status: "failed", run, updatedAt: now() };
244
+ if (!saved)
245
+ await saveArtifact(artifact);
232
246
  throw err;
233
247
  }
234
- await saveArtifact(artifact);
248
+ const merged = await updateArtifactByKey(artifactKey(artifact.id), (current) => mergeRunResult(artifact, current));
249
+ if (merged)
250
+ artifact = merged;
251
+ else
252
+ await saveArtifact(artifact);
235
253
  return { artifact, skipped: false };
236
254
  }
237
255
  /** Run tasks with bounded concurrency, preserving order of results. */
@@ -1,12 +1,12 @@
1
1
  import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
2
2
  import { evaluateAutoSend } from "../core/autosend.js";
3
3
  import { loadConfig } from "../core/config.js";
4
- import { classifyReply, currentLogin, fetchConversation, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
4
+ import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
5
5
  import { notice, notify } from "../core/notify.js";
6
6
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
7
7
  import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
8
8
  import { ReviewInProgressError } from "../runner/inflight.js";
9
- import { pool, reviewPr } from "../runner/review.js";
9
+ import { SETTLED_BY_YOU, pool, reviewPr } from "../runner/review.js";
10
10
  /**
11
11
  * A pure discovery stub: enough artifact for the queue to show an awaiting row.
12
12
  * The AI run that follows (automatic or clicked) replaces it wholesale.
@@ -45,6 +45,7 @@ export function stubArtifact(ref) {
45
45
  sent: null,
46
46
  refresh: null,
47
47
  filed: null,
48
+ settledAt: null,
48
49
  calibration: null,
49
50
  chat: [],
50
51
  preChat: null,
@@ -122,7 +123,58 @@ export function filedByWithdrawnRequest(artifact) {
122
123
  return false;
123
124
  return artifact.run?.trigger === "daemon";
124
125
  }
125
- /** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
126
+ /**
127
+ * When a settled row was settled, as well as cerber can date it — or null if it
128
+ * isn't settled at all, or a send already spoke for it.
129
+ *
130
+ * `settledAt` is the record and the only exact answer. Rows settled before that
131
+ * field existed fall back to the latest moment cerber can prove the decision
132
+ * came after: cerber filed it (`filed.at`), or you settled a draft, which you
133
+ * cannot have done before the draft existed (`run.finishedAt`). Both are lower
134
+ * bounds, so a stale one can only make this read a request as newer than it
135
+ * really was — one row coming back into the inbox once, against leaving every
136
+ * pre-existing settled row permanently unreachable, which is the bug.
137
+ *
138
+ * Not `updatedAt`: opening a settled review refreshes it onto the new head,
139
+ * which moves that field forward long after the decision it would be dating.
140
+ */
141
+ export function settledAtOf(a) {
142
+ if (!SETTLED_BY_YOU.has(a.status) || a.sent)
143
+ return null;
144
+ return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
145
+ }
146
+ /**
147
+ * Whether a review request is a *new* ask rather than the one you settled.
148
+ *
149
+ * A skip says "I am done with this PR", and a push does not undo that — the
150
+ * whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
151
+ * event: your skip answered the request that was open when you made it, and it
152
+ * cannot have answered one that came after. Withdrawn-then-re-added is the
153
+ * shape this catches, and it is not exotic — an author who pulls the request
154
+ * while you are mid-look and puts it back with the next push leaves the inbox
155
+ * silent about a review someone is now actively waiting on.
156
+ *
157
+ * Parsed, not compared as text, for the reason `filedByYourAct` gives: our
158
+ * timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
159
+ * before "…:00Z".
160
+ */
161
+ export function askedAgainAfterSettling(a, requestedAt) {
162
+ if (requestedAt === null)
163
+ return false;
164
+ const settled = settledAtOf(a);
165
+ return settled !== null && Date.parse(requestedAt) > Date.parse(settled);
166
+ }
167
+ /**
168
+ * What a reopened row goes back to being: the draft it has, or a row waiting
169
+ * for one. A run that failed left no draft to show, so it reopens as `awaiting`
170
+ * and the poll drafts it again.
171
+ */
172
+ export function reopenedStatus(a) {
173
+ return a.run?.finishedAt && !a.run.error ? "ready" : "awaiting";
174
+ }
175
+ /** How many artifacts each of the poll's GitHub re-checks — the open-PR state
176
+ * refresh, and the settled rows it asks about again — may spend per poll, and
177
+ * how often any one artifact is re-asked about. */
126
178
  const STATE_REFRESH_CAP = 10;
127
179
  const STATE_REFRESH_MIN_MS = 30 * 60_000;
128
180
  /**
@@ -162,6 +214,9 @@ export function startDaemon(opts) {
162
214
  /** When GitHub last confirmed an artifact's PR state, by artifact id. In-memory:
163
215
  * a restart just re-checks sooner, which is harmless. */
164
216
  const stateCheckedAt = new Map();
217
+ /** When GitHub was last asked whether a settled row has been re-requested, by
218
+ * artifact id. In-memory for the same reason: a restart just asks sooner. */
219
+ const requestCheckedAt = new Map();
165
220
  async function discover() {
166
221
  const filters = opts.repos.length > 0 ? opts.repos : [undefined];
167
222
  const seen = new Set();
@@ -183,9 +238,67 @@ export function startDaemon(opts) {
183
238
  */
184
239
  async function syncQueue(refs) {
185
240
  const discovered = [];
241
+ /** Reopen checks spent this poll — the same cap the state refresh below obeys. */
242
+ let reopenChecks = 0;
243
+ // Asked once per pass, and only if a row actually reaches a check that
244
+ // needs it. Undefined is "not asked yet"; null is "gh could not say", which
245
+ // attributes nothing to you rather than guessing.
246
+ let login;
247
+ /**
248
+ * Put a settled row back in the inbox when somebody has asked you again.
249
+ *
250
+ * The mirror of `fileIfSettledElsewhere`: that one files rows away once
251
+ * GitHub has moved past them, this one brings one back when GitHub has
252
+ * moved *to* it. Both exist because the queue's picture of who is waiting
253
+ * on what is local, and the thing it is a picture of lives on github.com.
254
+ *
255
+ * Only rows GitHub is asking about right now get here at all — the caller
256
+ * is looping over the awaiting search — so the extra read is one call for
257
+ * one question: was that request made after you settled?
258
+ */
259
+ async function reopenIfAskedAgain(artifact) {
260
+ if (settledAtOf(artifact) === null)
261
+ return;
262
+ const last = requestCheckedAt.get(artifact.id) ?? 0;
263
+ if (reopenChecks >= STATE_REFRESH_CAP || Date.now() - last < STATE_REFRESH_MIN_MS)
264
+ return;
265
+ reopenChecks++;
266
+ requestCheckedAt.set(artifact.id, Date.now());
267
+ if (login === undefined)
268
+ login = await currentLogin().catch(() => null);
269
+ if (login === null)
270
+ return;
271
+ let requestedAt;
272
+ try {
273
+ requestedAt = await fetchLastReviewRequest(artifact.pr, login);
274
+ }
275
+ catch {
276
+ // A read that failed is not evidence of anything, so nothing happens to
277
+ // the row. It is not re-asked about until the leash above expires: the
278
+ // stamp is spent before the call on purpose, so gh being down cannot
279
+ // turn every settled row into a retry on every poll.
280
+ return;
281
+ }
282
+ if (requestedAt === null || !askedAgainAfterSettling(artifact, requestedAt))
283
+ return;
284
+ const at = requestedAt;
285
+ // Re-read from disk: a re-review or a send may have started in the time
286
+ // that call took, and neither wants its status rewritten underneath it.
287
+ const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
288
+ ? // `filed` goes with the status it explained: cerber's account of why
289
+ // this row was settled is not the story of a row that is back.
290
+ { ...a, status: reopenedStatus(a), settledAt: null, filed: null }
291
+ : a);
292
+ // The status is what says the write landed. `settledAt` cannot: a legacy
293
+ // row never had one, so a mutation that declined would read as a reopen.
294
+ if (saved && !SETTLED_BY_YOU.has(saved.status)) {
295
+ log(`[${artifact.id}] your review was requested again on ${at.slice(0, 10)} — back in the inbox`);
296
+ }
297
+ }
186
298
  for (const ref of refs) {
187
299
  const existing = await loadArtifact(artifactId(ref));
188
300
  if (existing) {
301
+ await reopenIfAskedAgain(existing);
189
302
  // The search result is what the PR is right now; the artifact can
190
303
  // predate a draft→ready flip. Refresh from it — it costs nothing, it's
191
304
  // already in hand — or the queue keeps calling a ready PR a draft until
@@ -201,10 +314,6 @@ export function startDaemon(opts) {
201
314
  await saveArtifact(stubArtifact(ref));
202
315
  discovered.push(ref);
203
316
  }
204
- // Asked once per pass, and only if a row actually reaches the check that
205
- // needs it. Undefined is "not asked yet"; null is "gh could not say", which
206
- // attributes nothing to you rather than guessing.
207
- let login;
208
317
  /**
209
318
  * File a draft away once GitHub has moved past it without cerber's help.
210
319
  *
@@ -240,7 +349,7 @@ export function startDaemon(opts) {
240
349
  /** Re-reads from disk: a run or a send may have started in the seconds
241
350
  * these reads took, and neither wants filing out from under it. */
242
351
  async function file(filed, note, stillFilable) {
243
- const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed } : a);
352
+ const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed, settledAt: filed.at } : a);
244
353
  if (saved?.filed)
245
354
  log(`[${artifact.id}] ${note} — filed as reviewed`);
246
355
  }
@@ -15,7 +15,7 @@ import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/
15
15
  import { refreshArtifact } from "../core/refresh.js";
16
16
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
17
17
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
18
- import { listArtifacts, loadArtifact, loadArtifactByKey, reconcileRunning, saveArtifact, updateArtifactByKey, } from "../core/state.js";
18
+ import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
19
19
  import { isReviewRunning } from "../runner/inflight.js";
20
20
  import { reviewPr } from "../runner/review.js";
21
21
  import { runChatTurn } from "../runner/chat.js";
@@ -23,6 +23,20 @@ import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
23
23
  import { progressWriter } from "./progress.js";
24
24
  import { ChatTurnSchema } from "../core/artifact.js";
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ /**
27
+ * The statuses `PATCH /api/reviews/:key` will set: the two that are the user's
28
+ * own decision about a PR. Everything else is a fact some other path owns.
29
+ */
30
+ const SETTLEABLE = ["reviewed", "skipped"];
31
+ /**
32
+ * Is an AI run rewriting this draft right now?
33
+ *
34
+ * Two questions, because neither alone is enough: `isReviewRunning` is an
35
+ * in-memory claim and cannot see a `cerber review` going in another terminal,
36
+ * while the persisted status cannot see a run this process started moments ago
37
+ * that has not written yet.
38
+ */
39
+ const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
26
40
  export async function buildApp(opts) {
27
41
  const app = new Hono();
28
42
  if (opts.token) {
@@ -251,10 +265,12 @@ export async function buildApp(opts) {
251
265
  trusted: false,
252
266
  sessionId: null,
253
267
  trigger: "user",
268
+ reviewedSha: null,
254
269
  },
255
270
  sent: null,
256
271
  refresh: null,
257
272
  filed: null,
273
+ settledAt: null,
258
274
  calibration: null,
259
275
  chat: [],
260
276
  preChat: null,
@@ -284,10 +300,28 @@ export async function buildApp(opts) {
284
300
  // ---- Mutations (all local — artifact edits only) ----
285
301
  app.patch("/api/reviews/:key", async (c) => {
286
302
  const body = await c.req.json();
303
+ // Only the two statuses that are the user's own decision. The schema
304
+ // permits all seven, and taking them all meant this endpoint would happily
305
+ // write `status: "sent"` with no `sent` record and nothing submitted —
306
+ // a row that claims a review reached GitHub, which no honest path produces.
307
+ // `running`/`ready`/`failed` belong to the runner, `sent` to the send path.
308
+ if (body.status !== undefined && !SETTLEABLE.includes(body.status)) {
309
+ return c.json({ error: `status must be one of ${SETTLEABLE.join(", ")} — got ${body.status}` }, 400);
310
+ }
287
311
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => {
288
312
  const next = { ...a };
289
313
  if (body.status !== undefined) {
290
314
  next.status = ArtifactStatusSchema.parse(body.status);
315
+ // Dated on the way in, because nothing else can date it later: the poll
316
+ // asks whether a review request came before or after this decision, and
317
+ // `updatedAt` moves every time the review is opened and refreshed.
318
+ next.settledAt = new Date().toISOString();
319
+ // And cerber's own account of why this row was settled goes with the
320
+ // status it explained. `filed` outranks the status wherever the queue
321
+ // tags a row (`rowTag`, `requestTag`), on the grounds that "reviewed"
322
+ // would otherwise read as a click nobody made — which stops being true
323
+ // the moment you click. Same rule the poll's reopen follows.
324
+ next.filed = null;
291
325
  }
292
326
  if (body.verdictRecommendation !== undefined && next.verdict) {
293
327
  next.verdict = {
@@ -429,6 +463,7 @@ export async function buildApp(opts) {
429
463
  trusted: false,
430
464
  sessionId: null,
431
465
  trigger: "user",
466
+ reviewedSha: null,
432
467
  },
433
468
  }));
434
469
  // Minutes-long: run it detached and let the cockpit poll the artifact.
@@ -589,6 +624,13 @@ export async function buildApp(opts) {
589
624
  if (artifact.sent) {
590
625
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
591
626
  }
627
+ // Not while a run is rewriting this draft. Vouching for a review that is
628
+ // being replaced under you is reason enough on its own; on top of that the
629
+ // run used to save `sent: null` back over the record when it landed, so the
630
+ // guard above would wave a *second* submission through afterwards.
631
+ if (inFlight(artifact)) {
632
+ return c.json({ error: "a review of this PR is running — wait for it to finish" }, 409);
633
+ }
592
634
  const payload = buildReviewPayload(artifact, event);
593
635
  try {
594
636
  const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments, commitId: payload.commitId });
@@ -628,11 +670,13 @@ export async function buildApp(opts) {
628
670
  }
629
671
  return app;
630
672
  }
673
+ /**
674
+ * Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
675
+ * caller must have done it before starting the daemon, because the daemon polls
676
+ * as soon as it is constructed. Doing it here as well would have marked that
677
+ * poll's own fresh run as an interrupted leftover.
678
+ */
631
679
  export async function startServer(opts) {
632
- const cleared = await reconcileRunning();
633
- if (cleared > 0) {
634
- console.log(`Cleared ${cleared} AI run(s) left in flight by a previous process — start them again from the cockpit.`);
635
- }
636
680
  const app = await buildApp(opts);
637
681
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
638
682
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "author": "Full Stack House (https://fullstack.house)",