@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.
- package/README.md +10 -11
- package/config.example.toml +5 -1
- package/dist/extensions/vendor/openviking-pi/index.js +4 -0
- package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
- package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
- package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
- package/dist/src/cli.js +2 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.js +7 -4
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/ledgercheck.js +39 -10
- package/dist/src/ledgercheck.js.map +1 -1
- package/dist/src/session-command-supervisor.js +38 -9
- package/dist/src/session-command-supervisor.js.map +1 -1
- package/dist/src/state.js +73 -9
- package/dist/src/state.js.map +1 -1
- package/dist/src/tui/alerts.js +4 -1
- package/dist/src/tui/alerts.js.map +1 -1
- package/dist/src/tui/board.js +1 -1
- package/dist/src/tui/board.js.map +1 -1
- package/dist/src/verdicts.js +12 -0
- package/dist/src/verdicts.js.map +1 -1
- package/dist/src/wire.js +3 -2
- package/dist/src/wire.js.map +1 -1
- package/dist/src/workerbreaker.js +32 -5
- package/dist/src/workerbreaker.js.map +1 -1
- package/extensions/vendor/openviking-pi/NOTICE +5 -0
- package/extensions/vendor/openviking-pi/README.md +39 -5
- package/extensions/vendor/openviking-pi/index.ts +4 -0
- package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
- package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
- package/extensions/vendor/openviking-pi/sync.ts +80 -0
- package/extensions/vendor/openviking-pi/upstream.json +2 -1
- package/package.json +2 -2
- package/src/cli.ts +2 -0
- package/src/daemon.ts +8 -4
- package/src/ledgercheck.ts +45 -9
- package/src/session-command-supervisor.ts +41 -8
- package/src/state.ts +101 -11
- package/src/tui/alerts.ts +9 -2
- package/src/tui/board.ts +1 -1
- package/src/verdicts.ts +13 -0
- package/src/wire.ts +3 -2
- 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
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quickchatai/quickreview",
|
|
3
|
-
"version": "1.3.
|
|
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.
|
|
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.
|
|
958
|
-
log(
|
|
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
|
-
|
|
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,
|
package/src/ledgercheck.ts
CHANGED
|
@@ -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)
|
|
15
|
-
//
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
-
|
|
145
|
-
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
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
|
|
842
|
-
*
|
|
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
|
-
|
|
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> {
|