@quickchatai/quickreview 1.3.1 → 1.3.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.
Files changed (46) hide show
  1. package/README.md +10 -11
  2. package/config.example.toml +5 -1
  3. package/dist/extensions/vendor/openviking-pi/index.js +4 -0
  4. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
  5. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
  6. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
  7. package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
  8. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
  9. package/dist/src/cli.js +2 -0
  10. package/dist/src/cli.js.map +1 -1
  11. package/dist/src/daemon.js +7 -4
  12. package/dist/src/daemon.js.map +1 -1
  13. package/dist/src/ledgercheck.js +39 -10
  14. package/dist/src/ledgercheck.js.map +1 -1
  15. package/dist/src/session-command-supervisor.js +38 -9
  16. package/dist/src/session-command-supervisor.js.map +1 -1
  17. package/dist/src/state.js +73 -9
  18. package/dist/src/state.js.map +1 -1
  19. package/dist/src/tui/alerts.js +4 -1
  20. package/dist/src/tui/alerts.js.map +1 -1
  21. package/dist/src/tui/board.js +1 -1
  22. package/dist/src/tui/board.js.map +1 -1
  23. package/dist/src/verdicts.js +12 -0
  24. package/dist/src/verdicts.js.map +1 -1
  25. package/dist/src/wire.js +3 -2
  26. package/dist/src/wire.js.map +1 -1
  27. package/dist/src/workerbreaker.js +32 -5
  28. package/dist/src/workerbreaker.js.map +1 -1
  29. package/extensions/vendor/openviking-pi/NOTICE +5 -0
  30. package/extensions/vendor/openviking-pi/README.md +39 -5
  31. package/extensions/vendor/openviking-pi/index.ts +4 -0
  32. package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
  33. package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
  34. package/extensions/vendor/openviking-pi/sync.ts +80 -0
  35. package/extensions/vendor/openviking-pi/upstream.json +2 -1
  36. package/package.json +2 -2
  37. package/src/cli.ts +2 -0
  38. package/src/daemon.ts +8 -4
  39. package/src/ledgercheck.ts +45 -9
  40. package/src/session-command-supervisor.ts +41 -8
  41. package/src/state.ts +101 -11
  42. package/src/tui/alerts.ts +9 -2
  43. package/src/tui/board.ts +1 -1
  44. package/src/verdicts.ts +13 -0
  45. package/src/wire.ts +3 -2
  46. package/src/workerbreaker.ts +41 -5
@@ -0,0 +1,239 @@
1
+ diff --git a/extensions/vendor/openviking-pi/NOTICE b/extensions/vendor/openviking-pi/NOTICE
2
+ index 15992e4a..1afc0e01 100644
3
+ --- a/extensions/vendor/openviking-pi/NOTICE
4
+ +++ b/extensions/vendor/openviking-pi/NOTICE
5
+ @@ -60,6 +60,11 @@ Changed by the quickreviewd maintainers, 2026-08-20:
6
+ 12. The extension is a factory over a configuration value; the process's
7
+ environment, argv, working directory and home directory are read once, by
8
+ a shim, and every hook runs under a deadline and a catch (2026-10-19).
9
+ + 13. One turn sync runs at a time per session: a turn that ends while one
10
+ + runs queues its branch, the running sync sends the queued branches in
11
+ + order, each entry sent once by its pi entry id, and agent_end waits for
12
+ + them under turn_end's deadline, so a hook deadline can no longer start a
13
+ + second sync that re-sends the same messages (2026-10-29).
14
+
15
+ Plus vendoring scaffolding: build-conformance fixes for this repo's stricter
16
+ tsconfig (ambient type declarations while pi was not installed, retired once
17
+ diff --git a/extensions/vendor/openviking-pi/README.md b/extensions/vendor/openviking-pi/README.md
18
+ index 670e38b2..ca289c2d 100644
19
+ --- a/extensions/vendor/openviking-pi/README.md
20
+ +++ b/extensions/vendor/openviking-pi/README.md
21
+ @@ -1,7 +1,7 @@
22
+ # openviking-pi — vendored, patched fork
23
+
24
+ The OpenViking pi extension our sessions load. Vendored, not depended on:
25
+ -upstream ships it as an `examples/` directory, not a package, and we need twelve
26
+ +upstream ships it as an `examples/` directory, not a package, and we need thirteen
27
+ behaviour changes that upstream has no reason to make.
28
+
29
+ | | |
30
+ @@ -20,7 +20,7 @@ fails on drift between the two.
31
+
32
+ ## Why a fork rather than configuration
33
+
34
+ -Every one of the twelve patches was tried as configuration first and does not
35
+ +Every one of the thirteen patches was tried as configuration first and does not
36
+ exist there. The evidence is a spike against a **running v0.4.15 server plus
37
+ the pinned pi 0.84.1**, with transcripts, kept with the project's design
38
+ records. The short version: several of its findings contradict the OpenViking
39
+ @@ -29,12 +29,13 @@ anything here.
40
+
41
+ ## The patch series
42
+
43
+ -Twelve numbered behaviour patches, applied in order over the vendored upstream
44
+ +Thirteen numbered behaviour patches, applied in order over the vendored upstream
45
+ files, plus two unnumbered build patches (0000 scaffolding, 0012 typing). They live in `patches/` as
46
+ `git apply`-able diffs — verified by applying the first thirteen, in order, to a
47
+ pristine `v0.4.15` extraction and diffing the result against this directory:
48
+ -byte-identical. (0013 is checked to apply on the tree the first thirteen
49
+ -produce; the full pristine-extraction replay has not been re-run with it.) (`patches/` and `upstream.json` are meta and are not themselves
50
+ +byte-identical. (0013 and 0014 are checked to apply on the tree the patches
51
+ +before them produce; the full pristine-extraction replay has not been re-run
52
+ +with them.) (`patches/` and `upstream.json` are meta and are not themselves
53
+ part of the reproduced tree.) Every hunk they add carries a greppable marker:
54
+
55
+ ```sh
56
+ @@ -488,6 +489,39 @@ Touches: `index.ts`, `config.ts`, `sync.ts`, `qcr-leaf.ts`, `qcr-shim.ts`
57
+ (new), `qcr-hooks.ts` (new), `shared/pending-queue.mjs` and `.d.mts`, `NOTICE`,
58
+ this README.
59
+
60
+ +### 0014 — one turn sync in flight per session (`[QCR PATCH 13]`)
61
+ +
62
+ +The `turn_end` hook runs under a deadline (`qcr-hooks.ts`), and a deadline
63
+ +that passes returns without cancelling the hook's work. Upstream's
64
+ +`syncBranch` then ran on in the background, and under a slow server the next
65
+ +turn started a second sync from the same watermark — which only advances once
66
+ +a whole sync has finished. One process sent the same messages to one OV
67
+ +session several times at once; the server takes a tree lock per session
68
+ +append, so they queued on it, timed out client-side (and were queued for
69
+ +replay), and the health probes queued behind them. On the bot it was measured
70
+ +at up to five appends in flight to one session from its one worker.
71
+ +
72
+ +`SyncManager.syncBranch` now runs one sync at a time. A turn that ends while
73
+ +one runs queues its branch, in memory and in order; the running sync then
74
+ +sends each queued branch in turn, so the process sends nothing twice and
75
+ +nothing at once, and a later turn (a branch switch included) never overwrites
76
+ +an earlier one. Upstream's watermark is a count of entries, so a queued branch
77
+ +as long as the one before it would read as sent; what was sent is now
78
+ +remembered by pi's entry ids, and only entries without ids fall back to the
79
+ +count. The offline queue on disk is
80
+ +not used for this: it is shared by every worker, it collapses identical
81
+ +payloads and replays by write time, all of which would reorder or drop turns.
82
+ +
83
+ +A bot worker disposes its session without `session_shutdown`, so the queued
84
+ +turns must go out before the prompt resolves: `agent_end` awaits
85
+ +`SyncManager.drain()`, and its deadline in `qcr-hooks.ts` is raised from 1 s
86
+ +to turn_end's 30 s. With nothing queued, `drain()` settles at once; with a
87
+ +slow server the prompt can resolve up to 30 s later than before. Turns still
88
+ +queued when that deadline passes are lost with the worker, as a running sync
89
+ +was before this patch; the pi transcript keeps them.
90
+ +
91
+ +Touches: `sync.ts`, `index.ts`, `qcr-hooks.ts`, `NOTICE`, this README.
92
+ +
93
+ ## Rebasing onto a newer upstream
94
+
95
+ ```sh
96
+ diff --git a/extensions/vendor/openviking-pi/index.ts b/extensions/vendor/openviking-pi/index.ts
97
+ index 8bdc65d9..1b60eeee 100644
98
+ --- a/extensions/vendor/openviking-pi/index.ts
99
+ +++ b/extensions/vendor/openviking-pi/index.ts
100
+ @@ -405,6 +405,10 @@ async function register(pi: ExtensionAPI, cfg: OpenVikingConfig): Promise<void>
101
+ // --- agent_end ---
102
+ pi.on("agent_end", guard("agent_end", async (_event, _ctx) => {
103
+ recall.invalidate();
104
+ + // [QCR PATCH 13] The turns queued behind a slow sync go out before the
105
+ + // prompt resolves, under this hook's deadline: a worker disposes its
106
+ + // session without session_shutdown, so this is the last chance.
107
+ + await sync.drain();
108
+ }));
109
+
110
+ // ================================================================
111
+ diff --git a/extensions/vendor/openviking-pi/qcr-hooks.ts b/extensions/vendor/openviking-pi/qcr-hooks.ts
112
+ index 8979e729..89d8281c 100644
113
+ --- a/extensions/vendor/openviking-pi/qcr-hooks.ts
114
+ +++ b/extensions/vendor/openviking-pi/qcr-hooks.ts
115
+ @@ -32,7 +32,9 @@ export const HOOK_TIMEOUT_MS: Readonly<Record<HookName, number>> = {
116
+ tool_call: 2_000,
117
+ turn_end: 30_000,
118
+ session_shutdown: 5_000,
119
+ - agent_end: 1_000,
120
+ + // [QCR PATCH 13] agent_end drains the turn syncs queued behind a slow one,
121
+ + // so it gets turn_end's deadline; with nothing queued it returns at once.
122
+ + agent_end: 30_000,
123
+ command: 15_000,
124
+ };
125
+
126
+ diff --git a/extensions/vendor/openviking-pi/sync.ts b/extensions/vendor/openviking-pi/sync.ts
127
+ index 037d6306..ddfe651e 100644
128
+ --- a/extensions/vendor/openviking-pi/sync.ts
129
+ +++ b/extensions/vendor/openviking-pi/sync.ts
130
+ @@ -38,6 +38,14 @@ export class SyncManager {
131
+ private syncedEntryCount = 0;
132
+ private queue: PendingQueueConfig;
133
+ private debugLog: (message: string) => void;
134
+ + // [QCR PATCH 13] One turn sync in flight per session. A turn that ends
135
+ + // while one runs queues its branch here, in order; the running sync sends
136
+ + // each in turn. `idle` settles when the queue is empty again.
137
+ + private syncing = false;
138
+ + private waiting: any[][] = [];
139
+ + private idle: Promise<void> = Promise.resolve();
140
+ + // [QCR PATCH 13] The ids of the entries already sent (see syncOnce).
141
+ + private sentIds = new Set<string>();
142
+
143
+ constructor(client: OVClient, config: OVConfig, opts: SyncOptions = {}) {
144
+ this.client = client;
145
+ @@ -52,6 +60,7 @@ export class SyncManager {
146
+ restoreWatermark(n: number): void {
147
+ const next = Math.max(0, Math.floor(Number(n) || 0));
148
+ this.syncedEntryCount = next;
149
+ + this.sentIds.clear(); // [QCR PATCH 13] the next sync re-seeds from `next`
150
+ }
151
+
152
+ /**
153
+ @@ -110,8 +119,54 @@ export class SyncManager {
154
+ return countUndeliveredForSession(pending, this.ovSessionId) === 0;
155
+ }
156
+
157
+ + /**
158
+ + * [QCR PATCH 13] The turn_end hook's deadline returns without cancelling
159
+ + * this sync, so under a slow server the next turn used to start a second one
160
+ + * from the same watermark: the same messages went to one session several
161
+ + * times at once and queued on its lock. Now one sync runs at a time. A turn
162
+ + * that ends while it runs queues its branch, and the running sync sends the
163
+ + * queued branches one after another, each from the watermark the one before
164
+ + * it left: nothing is sent twice or at once, and no turn is overwritten by a
165
+ + * later one. `drain()` (awaited at agent_end) waits for the queue to empty.
166
+ + */
167
+ async syncBranch(branch: any[]): Promise<SyncBranchResult> {
168
+ if (!this.ovSessionId) return { added: 0, tokens: 0, allDelivered: true };
169
+ + if (this.syncing) {
170
+ + this.waiting.push(branch);
171
+ + return { added: 0, tokens: 0, allDelivered: false };
172
+ + }
173
+ + this.syncing = true;
174
+ + let done!: () => void;
175
+ + this.idle = new Promise<void>((resolve) => (done = resolve));
176
+ + try {
177
+ + const total = await this.syncOnce(branch);
178
+ + for (let next = this.waiting.shift(); next; next = this.waiting.shift()) {
179
+ + const r = await this.syncOnce(next);
180
+ + total.added += r.added;
181
+ + total.tokens += r.tokens;
182
+ + total.allDelivered = total.allDelivered && r.allDelivered;
183
+ + }
184
+ + return total;
185
+ + } finally {
186
+ + this.syncing = false;
187
+ + done();
188
+ + }
189
+ + }
190
+ +
191
+ + /** [QCR PATCH 13] Settles once no sync runs and no turn waits for one. */
192
+ + drain(): Promise<void> {
193
+ + return this.idle;
194
+ + }
195
+ +
196
+ + private async syncOnce(branch: any[]): Promise<SyncBranchResult> {
197
+ + // [QCR PATCH 13] A queued branch can be as long as the one sent before it
198
+ + // and share none of its entries (a branch switch), so a length watermark
199
+ + // would count its entries as sent. pi gives every entry an id: what was
200
+ + // sent is remembered by id. The watermark below stays for entries without.
201
+ + const ids = branch.map((entry) => entry?.id);
202
+ + if (branch.length > 0 && ids.every((id) => typeof id === "string")) {
203
+ + return this.syncById(branch, ids);
204
+ + }
205
+
206
+ const extracted = extractBranchCapturePayloads(branch, this.syncedEntryCount, this.config);
207
+ if (extracted.resetWatermark) this.syncedEntryCount = 0;
208
+ @@ -135,6 +190,31 @@ export class SyncManager {
209
+ return { added, tokens, allDelivered };
210
+ }
211
+
212
+ + /** [QCR PATCH 13] Sends each entry of `branch` not sent before, in order. */
213
+ + private async syncById(branch: any[], ids: string[]): Promise<SyncBranchResult> {
214
+ + // A restored watermark counts the branch's first entries as sent.
215
+ + if (this.sentIds.size === 0) {
216
+ + for (const id of ids.slice(0, this.syncedEntryCount)) this.sentIds.add(id);
217
+ + }
218
+ + let added = 0;
219
+ + let tokens = 0;
220
+ + let allDelivered = true;
221
+ + for (const [i, entry] of branch.entries()) {
222
+ + const id = ids[i]!;
223
+ + if (this.sentIds.has(id)) continue;
224
+ + for (const payload of extractBranchCapturePayloads([entry], 0, this.config).payloads) {
225
+ + const result = await this.addPayload(payload);
226
+ + if (!result.accepted) return { added, tokens, allDelivered };
227
+ + added++;
228
+ + tokens += estimatePayloadTokens(payload);
229
+ + allDelivered = allDelivered && result.delivered;
230
+ + }
231
+ + this.sentIds.add(id);
232
+ + }
233
+ + this.syncedEntryCount = branch.length;
234
+ + return { added, tokens, allDelivered };
235
+ + }
236
+ +
237
+ async addPayload(payload: any): Promise<AddPayloadResult> {
238
+ if (!this.ovSessionId) return { accepted: false, delivered: false };
239
+ const ok = await this.client.addMessagePayload(this.ovSessionId, payload);
@@ -32,7 +32,9 @@ export const HOOK_TIMEOUT_MS: Readonly<Record<HookName, number>> = {
32
32
  tool_call: 2_000,
33
33
  turn_end: 30_000,
34
34
  session_shutdown: 5_000,
35
- agent_end: 1_000,
35
+ // [QCR PATCH 13] agent_end drains the turn syncs queued behind a slow one,
36
+ // so it gets turn_end's deadline; with nothing queued it returns at once.
37
+ agent_end: 30_000,
36
38
  command: 15_000,
37
39
  };
38
40
 
@@ -38,6 +38,14 @@ export class SyncManager {
38
38
  private syncedEntryCount = 0;
39
39
  private queue: PendingQueueConfig;
40
40
  private debugLog: (message: string) => void;
41
+ // [QCR PATCH 13] One turn sync in flight per session. A turn that ends
42
+ // while one runs queues its branch here, in order; the running sync sends
43
+ // each in turn. `idle` settles when the queue is empty again.
44
+ private syncing = false;
45
+ private waiting: any[][] = [];
46
+ private idle: Promise<void> = Promise.resolve();
47
+ // [QCR PATCH 13] The ids of the entries already sent (see syncOnce).
48
+ private sentIds = new Set<string>();
41
49
 
42
50
  constructor(client: OVClient, config: OVConfig, opts: SyncOptions = {}) {
43
51
  this.client = client;
@@ -52,6 +60,7 @@ export class SyncManager {
52
60
  restoreWatermark(n: number): void {
53
61
  const next = Math.max(0, Math.floor(Number(n) || 0));
54
62
  this.syncedEntryCount = next;
63
+ this.sentIds.clear(); // [QCR PATCH 13] the next sync re-seeds from `next`
55
64
  }
56
65
 
57
66
  /**
@@ -110,8 +119,54 @@ export class SyncManager {
110
119
  return countUndeliveredForSession(pending, this.ovSessionId) === 0;
111
120
  }
112
121
 
122
+ /**
123
+ * [QCR PATCH 13] The turn_end hook's deadline returns without cancelling
124
+ * this sync, so under a slow server the next turn used to start a second one
125
+ * from the same watermark: the same messages went to one session several
126
+ * times at once and queued on its lock. Now one sync runs at a time. A turn
127
+ * that ends while it runs queues its branch, and the running sync sends the
128
+ * queued branches one after another, each from the watermark the one before
129
+ * it left: nothing is sent twice or at once, and no turn is overwritten by a
130
+ * later one. `drain()` (awaited at agent_end) waits for the queue to empty.
131
+ */
113
132
  async syncBranch(branch: any[]): Promise<SyncBranchResult> {
114
133
  if (!this.ovSessionId) return { added: 0, tokens: 0, allDelivered: true };
134
+ if (this.syncing) {
135
+ this.waiting.push(branch);
136
+ return { added: 0, tokens: 0, allDelivered: false };
137
+ }
138
+ this.syncing = true;
139
+ let done!: () => void;
140
+ this.idle = new Promise<void>((resolve) => (done = resolve));
141
+ try {
142
+ const total = await this.syncOnce(branch);
143
+ for (let next = this.waiting.shift(); next; next = this.waiting.shift()) {
144
+ const r = await this.syncOnce(next);
145
+ total.added += r.added;
146
+ total.tokens += r.tokens;
147
+ total.allDelivered = total.allDelivered && r.allDelivered;
148
+ }
149
+ return total;
150
+ } finally {
151
+ this.syncing = false;
152
+ done();
153
+ }
154
+ }
155
+
156
+ /** [QCR PATCH 13] Settles once no sync runs and no turn waits for one. */
157
+ drain(): Promise<void> {
158
+ return this.idle;
159
+ }
160
+
161
+ private async syncOnce(branch: any[]): Promise<SyncBranchResult> {
162
+ // [QCR PATCH 13] A queued branch can be as long as the one sent before it
163
+ // and share none of its entries (a branch switch), so a length watermark
164
+ // would count its entries as sent. pi gives every entry an id: what was
165
+ // sent is remembered by id. The watermark below stays for entries without.
166
+ const ids = branch.map((entry) => entry?.id);
167
+ if (branch.length > 0 && ids.every((id) => typeof id === "string")) {
168
+ return this.syncById(branch, ids);
169
+ }
115
170
 
116
171
  const extracted = extractBranchCapturePayloads(branch, this.syncedEntryCount, this.config);
117
172
  if (extracted.resetWatermark) this.syncedEntryCount = 0;
@@ -135,6 +190,31 @@ export class SyncManager {
135
190
  return { added, tokens, allDelivered };
136
191
  }
137
192
 
193
+ /** [QCR PATCH 13] Sends each entry of `branch` not sent before, in order. */
194
+ private async syncById(branch: any[], ids: string[]): Promise<SyncBranchResult> {
195
+ // A restored watermark counts the branch's first entries as sent.
196
+ if (this.sentIds.size === 0) {
197
+ for (const id of ids.slice(0, this.syncedEntryCount)) this.sentIds.add(id);
198
+ }
199
+ let added = 0;
200
+ let tokens = 0;
201
+ let allDelivered = true;
202
+ for (const [i, entry] of branch.entries()) {
203
+ const id = ids[i]!;
204
+ if (this.sentIds.has(id)) continue;
205
+ for (const payload of extractBranchCapturePayloads([entry], 0, this.config).payloads) {
206
+ const result = await this.addPayload(payload);
207
+ if (!result.accepted) return { added, tokens, allDelivered };
208
+ added++;
209
+ tokens += estimatePayloadTokens(payload);
210
+ allDelivered = allDelivered && result.delivered;
211
+ }
212
+ this.sentIds.add(id);
213
+ }
214
+ this.syncedEntryCount = branch.length;
215
+ return { added, tokens, allDelivered };
216
+ }
217
+
138
218
  async addPayload(payload: any): Promise<AddPayloadResult> {
139
219
  if (!this.ovSessionId) return { accepted: false, delivered: false };
140
220
  const ok = await this.client.addMessagePayload(this.ovSessionId, payload);
@@ -43,6 +43,7 @@
43
43
  "0010-typed-lessons.patch",
44
44
  "0011-review-session-memory-boundary.patch",
45
45
  "0012-typecheck-against-the-real-pi.patch",
46
- "0013-createopenviking-factory.patch"
46
+ "0013-createopenviking-factory.patch",
47
+ "0014-one-turn-sync-in-flight.patch"
47
48
  ]
48
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quickchatai/quickreview",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "private": false,
5
5
  "//private": "The root is the published `qcr` CLI: the whole app (TUI, daemon, companion, every verb), installed with npm, pnpm or bun. It depends on the pi plugin (packages/pi-plugin) at its exact version and on pi at the exact pins below. Publishing is a release step of its own, never part of a build.",
6
6
  "description": "A 24/7 AI code reviewer for GitHub: a deterministic daemon driving pi review sessions, with an operator TUI and a local qcr review",
@@ -115,7 +115,7 @@
115
115
  },
116
116
  "dependencies": {
117
117
  "@earendil-works/pi-ai": "1.4.0",
118
- "@quickchatai/pi-quickreview": "1.3.1",
118
+ "@quickchatai/pi-quickreview": "1.3.2",
119
119
  "@earendil-works/pi-coding-agent": "1.4.0",
120
120
  "better-sqlite3": "13.0.3",
121
121
  "smol-toml": "1.9.1"
package/src/cli.ts CHANGED
@@ -531,6 +531,8 @@ function drainHolderLine(snapshot: StatusSnapshot): string {
531
531
  /** What lifts the drain that stands. A snapshot with no hold marker comes from
532
532
  * a daemon that does not report holds: whether its boot lifts it is unknown. */
533
533
  function drainLiftLine(snapshot: StatusSnapshot): string {
534
+ if (snapshot.draining_reason && snapshot.draining_until && snapshot.draining_hold !== true)
535
+ return `lifts itself at ${snapshot.draining_until} and retries; qcr drain off to lift now`;
534
536
  if (snapshot.draining_hold === true || snapshot.draining_reason)
535
537
  return "only qcr drain off lifts it — no boot does";
536
538
  if (snapshot.draining_hold === undefined)
package/src/daemon.ts CHANGED
@@ -954,8 +954,10 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
954
954
  } else if (bootFirstPass && standing.draining && standing.reason !== null) {
955
955
  // A drain a system set for a reason (the worker crash-loop breaker,
956
956
  // `worker_failures`) is not a deploy window: a restart does not clear what
957
- // made it. Only an explicit `drain off` does.
958
- log(`drain stands — held for ${standing.reason}; only an explicit drain off lifts it`);
957
+ // made it. It lapses at its own expiry, or an explicit `drain off` lifts it.
958
+ log(
959
+ `drain stands — held for ${standing.reason}; ${standing.expires_at ? `it lifts itself at ${standing.expires_at}, or` : "only"} an explicit drain off lifts it`,
960
+ );
959
961
  } else if (bootFirstPass && report.errors.length === 0 && standing.draining) {
960
962
  // The operator's lift: this is the deploy flow's drain completing, so
961
963
  // it clears whatever hold stands — a session's included.
@@ -983,8 +985,10 @@ export function createDaemon(deps: DaemonDeps = {}): Daemon {
983
985
  const pump = async (): Promise<void> => {
984
986
  await guard("dispatch", async () => {
985
987
  // Session levers that lapsed since the last tick leave the ledger here;
986
- // the readers below already treat them as gone.
987
- sweepExpiredLevers(store, (line) => log(line));
988
+ // the readers below already treat them as gone. A drain that lapsed (a
989
+ // session's, or the worker breaker's healing itself) dropped thread
990
+ // replies as any drain does: sweep them as a lift would.
991
+ if (sweepExpiredLevers(store, (line) => log(line)).drain) threadDecisions.requestSweep();
988
992
  const orders = await dispatchOnce({
989
993
  cfg,
990
994
  store,
@@ -11,8 +11,14 @@
11
11
  // its ledger — no review work, no posting, no verdict file. A second stale
12
12
  // detection for the same round never nudges again: it logs
13
13
  // `ledger_stale_unresolved`, the grade that wakes the overseer (the session
14
- // ignored its one nudge). The once-mark is persisted BEFORE the inject, so
15
- // even a crashed inject can never turn into a nudge loop.
14
+ // ignored its one nudge), once per nudge key; later checks at the same key
15
+ // are a quiet `ledger_stale_repeat` event. The once-mark is persisted BEFORE
16
+ // the inject, so even a crashed inject can never turn into a nudge loop.
17
+ //
18
+ // A recheck uphold (round-less) updates the ledger only "if the delta settled
19
+ // or re-opened a finding" (prompts/round-recheck.md), so on that path a header
20
+ // naming the head the PR was last reviewed at, or a head a recheck upheld
21
+ // since, is fresh too.
16
22
  import { readFileSync } from "node:fs";
17
23
  import type { StateStore } from "./state.js";
18
24
  import { wakeOrHold } from "./wakes.js";
@@ -36,15 +42,32 @@ export function classifyLedger(
36
42
  content: string | null,
37
43
  pr: number,
38
44
  headSha: string,
45
+ alsoFresh: readonly string[] = [],
39
46
  ): LedgerFreshness {
40
47
  if (content === null) return "missing";
41
48
  const match = LEDGER_HEADER_RE.exec(content);
42
49
  if (!match) return "unparseable";
43
50
  if (Number(match[1]) !== pr) return "wrong_pr";
44
51
  const written = (match[2] as string).toLowerCase();
45
- const head = headSha.toLowerCase();
46
- const fresh = written === head || (written.length >= MIN_SHA_PREFIX && head.startsWith(written));
47
- return fresh ? "fresh" : "stale_sha";
52
+ const names = (sha: string): boolean => {
53
+ const head = sha.toLowerCase();
54
+ return written === head || (written.length >= MIN_SHA_PREFIX && head.startsWith(written));
55
+ };
56
+ return names(headSha) || alsoFresh.some(names) ? "fresh" : "stale_sha";
57
+ }
58
+
59
+ /** The heads a recheck uphold may find in the header besides its own: the head
60
+ * of the PR's last verdicted round and every head a recheck upheld since. */
61
+ export function upheldLedgerHeads(store: StateStore, pr: number): string[] {
62
+ const reviewed = store
63
+ .roundsFor(pr)
64
+ .filter((r) => r.verdict !== null)
65
+ .sort((a, b) => b.round_no - a.round_no)[0];
66
+ const since = reviewed ? (reviewed.finished_at ?? reviewed.started_at) : null;
67
+ return [
68
+ ...(reviewed?.head_sha ? [reviewed.head_sha] : []),
69
+ ...store.recheckUpheldHeads(pr, since),
70
+ ];
48
71
  }
49
72
 
50
73
  /** The re-nudge wake. Explicitly bookkeeping-only: the rewrite arrives
@@ -99,7 +122,12 @@ export async function enforceLedgerFreshness(
99
122
  } catch {
100
123
  content = null;
101
124
  }
102
- const freshness = classifyLedger(content, pr, headSha);
125
+ const freshness = classifyLedger(
126
+ content,
127
+ pr,
128
+ headSha,
129
+ roundNo === null ? upheldLedgerHeads(store, pr) : [],
130
+ );
103
131
  if (freshness === "fresh") return "fresh";
104
132
 
105
133
  const detail = `ledger ${freshness} at ${path}: expected header "LEDGER PR #${pr} @ ${headSha}"`;
@@ -140,9 +168,17 @@ export async function enforceLedgerFreshness(
140
168
  if (store.ledgerNudgeSent(pr, key)) {
141
169
  // Still stale AFTER the re-nudge: the session ignored its one nudge —
142
170
  // this grade WAKES the overseer (the severity map in src/anomalies.ts), first-detection
143
- // "ledger_stale" stays quiet (the nudge itself is the response).
144
- store.logAnomaly("ledger_stale_unresolved", pr, roundNo, detail);
145
- store.logEvent("ledger_stale_repeat", pr, { round_no: roundNo, head_sha: headSha, freshness });
171
+ // "ledger_stale" stays quiet (the nudge itself is the response). Raised
172
+ // once per key: a later check at the same key has no new nudge to give,
173
+ // so it is the quiet repeat event alone.
174
+ if (!store.ledgerRepeatSeen(pr, key))
175
+ store.logAnomaly("ledger_stale_unresolved", pr, roundNo, detail);
176
+ store.logEvent("ledger_stale_repeat", pr, {
177
+ key,
178
+ round_no: roundNo,
179
+ head_sha: headSha,
180
+ freshness,
181
+ });
146
182
  return "already_nudged";
147
183
  }
148
184
  store.logAnomaly("ledger_stale", pr, roundNo, detail);
@@ -4,7 +4,11 @@
4
4
  // scheduler. Delivery remains inject-bridge-owned; round recovery remains the
5
5
  // VerdictWatcher's existing self-requeue path. The supervisor only classifies
6
6
  // finite start/finish obligations from durable command state + JSONL receipts.
7
- import { roundStageDeadlines } from "@quickchatai/pi-quickreview/src/round-stage-deadlines.js";
7
+ import {
8
+ discoveryRowsOf,
9
+ roundStageAllowanceMin,
10
+ roundStageDeadlines,
11
+ } from "@quickchatai/pi-quickreview/src/round-stage-deadlines.js";
8
12
  import { existsSync } from "node:fs";
9
13
  import { join } from "node:path";
10
14
  import {
@@ -639,13 +643,15 @@ export class SessionCommandSupervisor {
639
643
  const now = this.now().getTime();
640
644
  const minute = 60_000;
641
645
  const sweepOwed = round.sweep_owed === 1;
646
+ const discoveryRows = discoveryRowsOf(round);
642
647
  const passesMaxMin =
643
648
  (this.deps.cfg.daemon.round_passes_max_min ?? DEFAULT_ROUND_PASSES_MAX_MIN) +
644
- (sweepOwed ? this.sweepExtraMin() : 0);
649
+ roundStageAllowanceMin({ sweepOwed, sweepExtraMin: this.sweepExtraMin(), discoveryRows });
645
650
  const deadlines = roundStageDeadlines(
646
651
  started,
647
652
  resolveRoundStageBounds(this.deps.cfg.daemon),
648
653
  sweepOwed,
654
+ discoveryRows,
649
655
  );
650
656
  const firstPassAt = deadlines.first_pass_start;
651
657
  const passesAt = deadlines.passes_complete;
@@ -657,9 +663,10 @@ export class SessionCommandSupervisor {
657
663
 
658
664
  // The extension's generic subagent milestone is descriptive only. The
659
665
  // finite gate opens solely when exact correlated progress names one of the
660
- // authoritative planned DISCOVERY lenses — validator-first, an added lens
661
- // or a foreign tool cannot buy a later failure class.
662
- const fanoutAgents = plannedFanoutAgents(progress, planned);
666
+ // DISCOVERY lenses the daemon dispatched — the planned ones or the round's
667
+ // optional rows. Validator-first, a lens added through the add protocol or
668
+ // a foreign tool cannot buy a later failure class.
669
+ const fanoutAgents = plannedFanoutAgents(progress, [...planned, ...optionalRowLenses(round)]);
663
670
  const plannedFanout = fanoutAgents.length > 0;
664
671
  if (plannedFanout) {
665
672
  const firstStarted = fanoutAgents.map((agent) => agent.started_at!).sort()[0]!;
@@ -838,8 +845,9 @@ export class SessionCommandSupervisor {
838
845
  }
839
846
 
840
847
  /** The command's fixed running cap in minutes: the configured bound, plus
841
- * the sweep allowance when it carries a round whose pre-approval sweep is
842
- * owed. Derived from the round ROW, never from progress. */
848
+ * the round's stage allowance (the pre-approval sweep's when it is owed, or
849
+ * a large plan's, whichever is larger). Derived from the round ROW, never
850
+ * from progress. */
843
851
  private runningMaxMin(command: SessionCommandRow, round?: RoundRow): number {
844
852
  const base =
845
853
  this.deps.cfg.daemon.session_command_running_max_min ??
@@ -848,7 +856,15 @@ export class SessionCommandSupervisor {
848
856
  const row =
849
857
  round ??
850
858
  this.deps.store.roundsFor(command.pr).find((item) => item.round_no === command.round_no);
851
- return row?.sweep_owed === 1 ? base + this.sweepExtraMin() : base;
859
+ if (!row) return base;
860
+ return (
861
+ base +
862
+ roundStageAllowanceMin({
863
+ sweepOwed: row.sweep_owed === 1,
864
+ sweepExtraMin: this.sweepExtraMin(),
865
+ discoveryRows: discoveryRowsOf(row),
866
+ })
867
+ );
852
868
  }
853
869
 
854
870
  /** The fixed running deadline (epoch ms) of a command whose run began at
@@ -1123,6 +1139,23 @@ function firstExecutionPerLens(
1123
1139
  return first;
1124
1140
  }
1125
1141
 
1142
+ /** The lenses of the optional rows the daemon dispatched with the round (the
1143
+ * addable record's `optional` entries); a lens offered for the add protocol
1144
+ * is not one. */
1145
+ function optionalRowLenses(round: RoundRow): string[] {
1146
+ if (!round.addable_passes) return [];
1147
+ try {
1148
+ const rows: unknown = JSON.parse(round.addable_passes);
1149
+ if (!Array.isArray(rows)) return [];
1150
+ return rows
1151
+ .filter((row) => (row as { optional?: unknown } | null)?.optional === true)
1152
+ .map((row) => (row as { lens?: unknown }).lens)
1153
+ .filter((lens): lens is string => typeof lens === "string");
1154
+ } catch {
1155
+ return [];
1156
+ }
1157
+ }
1158
+
1126
1159
  /** Plan rows per lens name from the round's recorded `pass_plan`; empty when
1127
1160
  * it is absent or unreadable (every lens then owes one execution). */
1128
1161
  function plannedRowCounts(round: RoundRow): Map<string, number> {