@in-the-loop-labs/pair-review 3.5.2 → 3.7.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 +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const { broadcastReviewEvent } = require('../events/review-events');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
const { makeAbortError } = require('./abort-signal-wiring');
|
|
6
|
+
|
|
7
|
+
const BACKGROUND_QUEUE_CONCURRENCY = 2;
|
|
8
|
+
|
|
9
|
+
const defaults = {
|
|
10
|
+
broadcast: broadcastReviewEvent,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bounded-concurrency in-process queue with per-key dedup.
|
|
15
|
+
*
|
|
16
|
+
* Jobs are keyed by `${reviewId}:${jobType}`; concurrent enqueues
|
|
17
|
+
* for the same key share a single execution and a single promise.
|
|
18
|
+
*
|
|
19
|
+
* Cancellation: each enqueued job is associated with an `AbortController`.
|
|
20
|
+
* The worker thunk is invoked as `fn(signal)` so it can plumb the signal
|
|
21
|
+
* into downstream provider calls / `fetch` / `child_process.spawn`.
|
|
22
|
+
* Callers cancel via `cancel(reviewId, jobKey)`, which aborts the
|
|
23
|
+
* signal and removes the controller; the worker is expected to react
|
|
24
|
+
* to the abort and settle (typically by rejecting with an AbortError).
|
|
25
|
+
*/
|
|
26
|
+
class BackgroundQueue {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {number} [options.concurrency] - Max concurrent jobs.
|
|
30
|
+
* @param {Object} [options._deps] - Override dependencies (testing).
|
|
31
|
+
* @param {Function} [options._deps.broadcast] - Broadcast hook.
|
|
32
|
+
*/
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
const { concurrency = BACKGROUND_QUEUE_CONCURRENCY, _deps = {} } = options;
|
|
35
|
+
this.concurrency = concurrency;
|
|
36
|
+
this.active = 0;
|
|
37
|
+
this.queue = [];
|
|
38
|
+
this.inFlight = new Map();
|
|
39
|
+
// Per-key AbortController covers both queued and running jobs so a
|
|
40
|
+
// single lookup can resolve cancellation against either state.
|
|
41
|
+
this.controllers = new Map();
|
|
42
|
+
this._deps = { ...defaults, ..._deps };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Enqueue a job for execution.
|
|
47
|
+
*
|
|
48
|
+
* Dedup contract: if a job for the same `(reviewId, jobType)` key is
|
|
49
|
+
* already queued or running, this returns the existing promise without
|
|
50
|
+
* invoking `fn`. The duplicate `fn` is silently dropped.
|
|
51
|
+
*
|
|
52
|
+
* The thunk is called as `fn(signal)` where `signal` is the `AbortSignal`
|
|
53
|
+
* for this job. Workers that touch the network, spawn processes, or
|
|
54
|
+
* otherwise burn upstream resources should thread the signal through so
|
|
55
|
+
* cancellation actually frees those resources.
|
|
56
|
+
*
|
|
57
|
+
* @param {string|number} reviewId - Review identifier.
|
|
58
|
+
* @param {string} jobType - Job category (e.g. 'summaries', 'tour').
|
|
59
|
+
* @param {Function} fn - Thunk `(signal) => value|Promise<value>`.
|
|
60
|
+
* @returns {Promise} Resolves/rejects with the job result.
|
|
61
|
+
*/
|
|
62
|
+
enqueue(reviewId, jobType, fn) {
|
|
63
|
+
const key = `${reviewId}:${jobType}`;
|
|
64
|
+
if (this.inFlight.has(key)) {
|
|
65
|
+
return this.inFlight.get(key);
|
|
66
|
+
}
|
|
67
|
+
let resolve;
|
|
68
|
+
let reject;
|
|
69
|
+
const p = new Promise((res, rej) => {
|
|
70
|
+
resolve = res;
|
|
71
|
+
reject = rej;
|
|
72
|
+
});
|
|
73
|
+
this.inFlight.set(key, p);
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
this.controllers.set(key, controller);
|
|
76
|
+
this.queue.push({
|
|
77
|
+
key,
|
|
78
|
+
run: fn,
|
|
79
|
+
resolve,
|
|
80
|
+
reject,
|
|
81
|
+
reviewId,
|
|
82
|
+
jobType,
|
|
83
|
+
controller,
|
|
84
|
+
promise: p,
|
|
85
|
+
});
|
|
86
|
+
this._drain();
|
|
87
|
+
return p;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Cancel an in-flight or queued job. Aborts its `AbortSignal` so the
|
|
92
|
+
* worker can tear down upstream resources, then drops the controller.
|
|
93
|
+
*
|
|
94
|
+
* Matching is exact on `(reviewId, jobKey)`. For composite jobTypes
|
|
95
|
+
* like `summaries:${digest}`, callers may also pass a bare prefix
|
|
96
|
+
* (`summaries`) — this cancels ALL matching `summaries:*` jobs for the
|
|
97
|
+
* review. This is what the toolbar "Cancel Summaries" button needs:
|
|
98
|
+
* users don't know about digests, they just want the pulse to stop.
|
|
99
|
+
*
|
|
100
|
+
* @param {string|number} reviewId
|
|
101
|
+
* @param {string} jobKey - bare `jobType` (e.g. `tour`) or full key
|
|
102
|
+
* suffix (e.g. `summaries:abc123`).
|
|
103
|
+
* @returns {{cancelled: number}} number of jobs aborted.
|
|
104
|
+
*/
|
|
105
|
+
cancel(reviewId, jobKey) {
|
|
106
|
+
if (jobKey === undefined || jobKey === null || jobKey === '') {
|
|
107
|
+
return { cancelled: 0 };
|
|
108
|
+
}
|
|
109
|
+
const exact = `${reviewId}:${jobKey}`;
|
|
110
|
+
const prefix = `${exact}:`;
|
|
111
|
+
let cancelled = 0;
|
|
112
|
+
// Snapshot keys before aborting — settling a worker mid-iteration would
|
|
113
|
+
// mutate this.controllers (via _settle).
|
|
114
|
+
const keys = Array.from(this.controllers.keys());
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (key !== exact && !key.startsWith(prefix)) continue;
|
|
117
|
+
const controller = this.controllers.get(key);
|
|
118
|
+
if (!controller) continue;
|
|
119
|
+
try {
|
|
120
|
+
controller.abort();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
logger.warn(`BackgroundQueue controller.abort() failed for ${key}: ${err.message}`);
|
|
123
|
+
}
|
|
124
|
+
// Eagerly evict the cancelled key from the dedup/controller maps and
|
|
125
|
+
// splice any not-yet-started descriptors out of the queue. Without
|
|
126
|
+
// this, a follow-up enqueue() for the same key would hit the dedup
|
|
127
|
+
// guard and inherit the about-to-reject promise, and _drain() could
|
|
128
|
+
// hand the worker an already-aborted signal. _settle()'s deletes are
|
|
129
|
+
// identity-guarded, so when the cancelled worker eventually rejects
|
|
130
|
+
// it won't clobber a replacement job installed under the same key.
|
|
131
|
+
this._evictKey(key);
|
|
132
|
+
cancelled++;
|
|
133
|
+
}
|
|
134
|
+
return { cancelled };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Remove a key from the dedup/controller maps and reject any queued (not
|
|
139
|
+
* yet started) descriptor with an AbortError. Safe to call when the key
|
|
140
|
+
* has already been cleaned up — Map.delete and Array.splice both no-op.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} key - Composite `${reviewId}:${jobType}` key.
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
_evictKey(key) {
|
|
146
|
+
// Splice queued descriptors and reject their promises so the dedup'd
|
|
147
|
+
// caller (if any) sees a clean cancellation rather than a hung promise.
|
|
148
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
149
|
+
if (this.queue[i].key !== key) continue;
|
|
150
|
+
const [descriptor] = this.queue.splice(i, 1);
|
|
151
|
+
try {
|
|
152
|
+
descriptor.reject(makeAbortError('Job cancelled before start'));
|
|
153
|
+
} catch (rejectErr) {
|
|
154
|
+
logger.warn(
|
|
155
|
+
`BackgroundQueue descriptor.reject failed for ${key}: ${rejectErr.message}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
this.inFlight.delete(key);
|
|
160
|
+
this.controllers.delete(key);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Start as many queued jobs as concurrency allows. */
|
|
164
|
+
_drain() {
|
|
165
|
+
while (this.active < this.concurrency && this.queue.length > 0) {
|
|
166
|
+
const descriptor = this.queue.shift();
|
|
167
|
+
this.active++;
|
|
168
|
+
Promise.resolve()
|
|
169
|
+
.then(() => descriptor.run(descriptor.controller.signal))
|
|
170
|
+
.then(
|
|
171
|
+
(result) => this._settle(descriptor, null, result),
|
|
172
|
+
(error) => this._settle(descriptor, error, undefined)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Finalize a job: free its key, broadcast, settle, and drain. */
|
|
178
|
+
_settle(descriptor, error, result) {
|
|
179
|
+
// Identity-guarded cleanup: if cancel() evicted this descriptor and a
|
|
180
|
+
// replacement was enqueued under the same key, the maps now point at
|
|
181
|
+
// the new descriptor's controller/promise — unconditional deletes would
|
|
182
|
+
// wipe the replacement's bookkeeping (invisible to hasActiveForReview,
|
|
183
|
+
// immune to cancel, vulnerable to duplicate enqueue).
|
|
184
|
+
if (this.controllers.get(descriptor.key) === descriptor.controller) {
|
|
185
|
+
this.controllers.delete(descriptor.key);
|
|
186
|
+
}
|
|
187
|
+
if (this.inFlight.get(descriptor.key) === descriptor.promise) {
|
|
188
|
+
this.inFlight.delete(descriptor.key);
|
|
189
|
+
}
|
|
190
|
+
this.active--;
|
|
191
|
+
this._onComplete(descriptor.reviewId, descriptor.jobType, error);
|
|
192
|
+
if (error === null) {
|
|
193
|
+
descriptor.resolve(result);
|
|
194
|
+
} else {
|
|
195
|
+
descriptor.reject(error);
|
|
196
|
+
}
|
|
197
|
+
this._drain();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Is there an in-flight or queued job for this review whose jobType
|
|
202
|
+
* starts with the given prefix? Useful for surfacing a "generating"
|
|
203
|
+
* indicator on the frontend (`hasActiveForReview(id, 'summaries')`).
|
|
204
|
+
*
|
|
205
|
+
* Job keys are stored as `${reviewId}:${jobType}`; `summaries` jobs use
|
|
206
|
+
* the form `summaries:${digest}`, so a prefix match on
|
|
207
|
+
* `${reviewId}:summaries` catches every digest variant.
|
|
208
|
+
*
|
|
209
|
+
* @param {string|number} reviewId
|
|
210
|
+
* @param {string} jobTypePrefix
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
hasActiveForReview(reviewId, jobTypePrefix) {
|
|
214
|
+
if (!jobTypePrefix) return false;
|
|
215
|
+
const prefix = `${reviewId}:${jobTypePrefix}`;
|
|
216
|
+
for (const key of this.inFlight.keys()) {
|
|
217
|
+
if (key === prefix || key.startsWith(prefix + ':')) return true;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Like `hasActiveForReview` but returns the in-flight/queued jobType string
|
|
224
|
+
* (without the `${reviewId}:` prefix), or null. Useful when callers track a
|
|
225
|
+
* versioned key like `summaries:${digest}` and need to know the *exact* key
|
|
226
|
+
* to cancel before re-enqueueing under a new digest.
|
|
227
|
+
*
|
|
228
|
+
* @param {string|number} reviewId
|
|
229
|
+
* @param {string} jobTypePrefix
|
|
230
|
+
* @returns {string|null}
|
|
231
|
+
*/
|
|
232
|
+
findActiveJobType(reviewId, jobTypePrefix) {
|
|
233
|
+
if (!jobTypePrefix) return null;
|
|
234
|
+
const prefix = `${reviewId}:${jobTypePrefix}`;
|
|
235
|
+
for (const key of this.inFlight.keys()) {
|
|
236
|
+
if (key === prefix || key.startsWith(prefix + ':')) {
|
|
237
|
+
return key.slice(String(reviewId).length + 1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Broadcast job completion; broadcast failures are logged, not thrown. */
|
|
244
|
+
_onComplete(reviewId, jobType, error) {
|
|
245
|
+
try {
|
|
246
|
+
// Include whether more jobs of the same type-prefix remain queued or
|
|
247
|
+
// in-flight so listeners (e.g. the summaries toolbar pulse) don't
|
|
248
|
+
// clear their "generating" state when a sibling job is still running.
|
|
249
|
+
// For composite types like `summaries:${digest}`, we strip the suffix
|
|
250
|
+
// so the prefix match catches every digest variant.
|
|
251
|
+
const colonIdx = jobType.indexOf(':');
|
|
252
|
+
const prefix = colonIdx >= 0 ? jobType.slice(0, colonIdx) : jobType;
|
|
253
|
+
const hasActiveForType = this.hasActiveForReview(reviewId, prefix);
|
|
254
|
+
this._deps.broadcast(reviewId, {
|
|
255
|
+
type: 'review:background_job_finished',
|
|
256
|
+
jobType,
|
|
257
|
+
ok: error === null,
|
|
258
|
+
hasActiveForType,
|
|
259
|
+
cancelled: isAbortError(error),
|
|
260
|
+
});
|
|
261
|
+
} catch (broadcastError) {
|
|
262
|
+
logger.warn(
|
|
263
|
+
`BackgroundQueue broadcast failed for ${reviewId}:${jobType}: ${broadcastError.message}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Recognize errors that originated from `AbortController.abort()`.
|
|
271
|
+
* Node sets `name === 'AbortError'` on the DOMException for AbortSignal,
|
|
272
|
+
* but providers that wrap the abort in a custom Error may instead set
|
|
273
|
+
* `code === 'ABORT_ERR'` or surface `signal.aborted` themselves. Check
|
|
274
|
+
* the common shapes so the broadcast payload is honest about cancels.
|
|
275
|
+
*
|
|
276
|
+
* @param {unknown} err
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
function isAbortError(err) {
|
|
280
|
+
if (!err) return false;
|
|
281
|
+
if (typeof err !== 'object') return false;
|
|
282
|
+
if (err.name === 'AbortError') return true;
|
|
283
|
+
if (err.code === 'ABORT_ERR') return true;
|
|
284
|
+
if (err.isCancellation === true) return true;
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const backgroundQueue = new BackgroundQueue();
|
|
289
|
+
|
|
290
|
+
module.exports = { BackgroundQueue, backgroundQueue, BACKGROUND_QUEUE_CONCURRENCY, isAbortError };
|
package/src/ai/claude-cli.js
CHANGED
|
@@ -100,7 +100,7 @@ class ClaudeCLI {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// Extract JSON from the text response using robust extraction strategies
|
|
103
|
-
const extracted = extractJSON(stdout, level);
|
|
103
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
104
104
|
if (extracted.success) {
|
|
105
105
|
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
106
106
|
resolve(extracted.data);
|
|
@@ -12,6 +12,7 @@ const logger = require('../utils/logger');
|
|
|
12
12
|
const { extractJSON } = require('../utils/json-extractor');
|
|
13
13
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
14
14
|
const { StreamParser, parseClaudeLine } = require('./stream-parser');
|
|
15
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
15
16
|
|
|
16
17
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
17
18
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -286,7 +287,7 @@ class ClaudeProvider extends AIProvider {
|
|
|
286
287
|
*/
|
|
287
288
|
async execute(prompt, options = {}) {
|
|
288
289
|
return new Promise((resolve, reject) => {
|
|
289
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
290
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
290
291
|
|
|
291
292
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
292
293
|
logger.info(`${levelPrefix} Executing Claude CLI...`);
|
|
@@ -300,7 +301,12 @@ class ClaudeProvider extends AIProvider {
|
|
|
300
301
|
...this.extraEnv,
|
|
301
302
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
302
303
|
},
|
|
303
|
-
shell: this.useShell
|
|
304
|
+
shell: this.useShell,
|
|
305
|
+
// In shell mode the immediate child is `/bin/sh -c '...claude...'`.
|
|
306
|
+
// Detaching makes the shell its own process-group leader so
|
|
307
|
+
// wireAbortToChild can `process.kill(-pid, SIGTERM)` and reap the
|
|
308
|
+
// CLI grandchild along with the shell. No effect when shell:false.
|
|
309
|
+
detached: this.useShell
|
|
304
310
|
});
|
|
305
311
|
|
|
306
312
|
const pid = claude.pid;
|
|
@@ -314,6 +320,13 @@ class ClaudeProvider extends AIProvider {
|
|
|
314
320
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
315
321
|
}
|
|
316
322
|
|
|
323
|
+
// Wire AbortSignal -> SIGTERM. Tour/summary jobs run through the
|
|
324
|
+
// BackgroundQueue which threads its per-job signal here so a user
|
|
325
|
+
// "Cancel" click stops burning tokens on the upstream CLI call.
|
|
326
|
+
// Pass shell: this.useShell so the helper signals the whole process
|
|
327
|
+
// group (group-kill) instead of just the shell wrapper.
|
|
328
|
+
const abortWiring = wireAbortToChild(claude, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
329
|
+
|
|
317
330
|
let stdout = '';
|
|
318
331
|
let stderr = '';
|
|
319
332
|
let timeoutId = null;
|
|
@@ -321,10 +334,16 @@ class ClaudeProvider extends AIProvider {
|
|
|
321
334
|
let lineBuffer = ''; // Buffer for incomplete JSONL lines
|
|
322
335
|
let lineCount = 0; // Count of JSONL events for progress tracking
|
|
323
336
|
|
|
337
|
+
// Centralize abort-listener cleanup in `settle` so it ALWAYS runs
|
|
338
|
+
// when this execute() returns — including when the timeout path
|
|
339
|
+
// settles before the child exits. Otherwise the abort listener
|
|
340
|
+
// outlives the call and leaks across the loop tour/summary
|
|
341
|
+
// generators run with a shared per-job signal.
|
|
324
342
|
const settle = (fn, value) => {
|
|
325
343
|
if (settled) return;
|
|
326
344
|
settled = true;
|
|
327
345
|
if (timeoutId) clearTimeout(timeoutId);
|
|
346
|
+
abortWiring.detach();
|
|
328
347
|
fn(value);
|
|
329
348
|
};
|
|
330
349
|
|
|
@@ -375,11 +394,24 @@ class ClaudeProvider extends AIProvider {
|
|
|
375
394
|
claude.on('close', (code) => {
|
|
376
395
|
if (settled) return; // Already settled by timeout or error
|
|
377
396
|
|
|
397
|
+
// Note: abort listener detach is centralized in `settle` so it
|
|
398
|
+
// runs even when the timeout path settled first.
|
|
399
|
+
|
|
378
400
|
// Flush any remaining stream parser buffer
|
|
379
401
|
if (streamParser) {
|
|
380
402
|
streamParser.flush();
|
|
381
403
|
}
|
|
382
404
|
|
|
405
|
+
// BackgroundQueue-driven cancellation: the user clicked "Cancel
|
|
406
|
+
// Tour"/"Cancel Summaries", which aborted our signal and we sent
|
|
407
|
+
// SIGTERM via wireAbortToChild. Surface it as an AbortError so
|
|
408
|
+
// upstream callers can distinguish "user cancel" from real failure.
|
|
409
|
+
if (abortWiring.cancelled()) {
|
|
410
|
+
logger.info(`${levelPrefix} Claude CLI terminated by user cancel (exit code ${code})`);
|
|
411
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
383
415
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
384
416
|
const isCancellationCode = code === 143 || code === 137;
|
|
385
417
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -410,13 +442,23 @@ class ClaudeProvider extends AIProvider {
|
|
|
410
442
|
const parsed = this.parseClaudeResponse(stdout, level, levelPrefix);
|
|
411
443
|
if (parsed.success) {
|
|
412
444
|
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
413
|
-
// Dump the parsed data for debugging
|
|
414
|
-
|
|
415
|
-
|
|
445
|
+
// Dump the parsed data for debugging.
|
|
446
|
+
// Skip for summary calls — they run per-file and the dump is per-call
|
|
447
|
+
// noise. The `[response]` line below already gives a useful one-liner.
|
|
448
|
+
const isSummaryCall = typeof levelPrefix === 'string' && levelPrefix.startsWith('[Summary');
|
|
449
|
+
if (!isSummaryCall) {
|
|
450
|
+
const dataPreview = JSON.stringify(parsed.data, null, 2);
|
|
451
|
+
logger.debug(`${levelPrefix} [parsed_data] ${dataPreview.substring(0, 3000)}${dataPreview.length > 3000 ? '...' : ''}`);
|
|
452
|
+
}
|
|
416
453
|
// Log suggestion count if present
|
|
417
454
|
if (parsed.data?.suggestions) {
|
|
418
455
|
const count = Array.isArray(parsed.data.suggestions) ? parsed.data.suggestions.length : 0;
|
|
419
456
|
logger.info(`${levelPrefix} [response] ${count} suggestions in parsed response`);
|
|
457
|
+
} else if (isSummaryCall && Array.isArray(parsed.data?.summaries)) {
|
|
458
|
+
const total = parsed.data.summaries.length;
|
|
459
|
+
const withText = parsed.data.summaries.filter((s) => s && typeof s.summary === 'string' && s.summary.length > 0).length;
|
|
460
|
+
const skipped = parsed.data.summaries.filter((s) => s && s.summary === null).length;
|
|
461
|
+
logger.info(`${levelPrefix} [response] ${total} summaries (${withText} with text, ${skipped} null)`);
|
|
420
462
|
}
|
|
421
463
|
settle(resolve, parsed.data);
|
|
422
464
|
} else {
|
|
@@ -451,6 +493,7 @@ class ClaudeProvider extends AIProvider {
|
|
|
451
493
|
|
|
452
494
|
// Handle errors
|
|
453
495
|
claude.on('error', (error) => {
|
|
496
|
+
// Detach happens inside `settle` below.
|
|
454
497
|
if (error.code === 'ENOENT') {
|
|
455
498
|
logger.error(`${levelPrefix} Claude CLI not found. Please ensure Claude CLI is installed.`);
|
|
456
499
|
settle(reject, new Error(`${levelPrefix} Claude CLI not found. ${ClaudeProvider.getInstallInstructions()}`));
|
|
@@ -756,7 +799,7 @@ class ClaudeProvider extends AIProvider {
|
|
|
756
799
|
if (textContent) {
|
|
757
800
|
logger.debug(`${levelPrefix} Extracted ${textContent.length} chars of text content from JSONL`);
|
|
758
801
|
// Try to extract JSON from the accumulated text content
|
|
759
|
-
const extracted = extractJSON(textContent, level);
|
|
802
|
+
const extracted = extractJSON(textContent, level, levelPrefix);
|
|
760
803
|
if (extracted.success) {
|
|
761
804
|
return extracted;
|
|
762
805
|
}
|
|
@@ -774,7 +817,7 @@ class ClaudeProvider extends AIProvider {
|
|
|
774
817
|
|
|
775
818
|
} catch (parseError) {
|
|
776
819
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
777
|
-
const extracted = extractJSON(stdout, level);
|
|
820
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
778
821
|
if (extracted.success) {
|
|
779
822
|
return extracted;
|
|
780
823
|
}
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -13,6 +13,7 @@ const logger = require('../utils/logger');
|
|
|
13
13
|
const { extractJSON } = require('../utils/json-extractor');
|
|
14
14
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
15
15
|
const { StreamParser, parseCodexLine } = require('./stream-parser');
|
|
16
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
16
17
|
|
|
17
18
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
18
19
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -242,7 +243,7 @@ class CodexProvider extends AIProvider {
|
|
|
242
243
|
*/
|
|
243
244
|
async execute(prompt, options = {}) {
|
|
244
245
|
return new Promise((resolve, reject) => {
|
|
245
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
246
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
246
247
|
|
|
247
248
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
248
249
|
logger.info(`${levelPrefix} Executing Codex CLI...`);
|
|
@@ -255,7 +256,10 @@ class CodexProvider extends AIProvider {
|
|
|
255
256
|
...this.extraEnv,
|
|
256
257
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
257
258
|
},
|
|
258
|
-
shell: this.useShell
|
|
259
|
+
shell: this.useShell,
|
|
260
|
+
// Detach in shell mode so wireAbortToChild can group-kill via
|
|
261
|
+
// process.kill(-pid). See claude-provider for the rationale.
|
|
262
|
+
detached: this.useShell
|
|
259
263
|
});
|
|
260
264
|
|
|
261
265
|
const pid = codex.pid;
|
|
@@ -267,6 +271,10 @@ class CodexProvider extends AIProvider {
|
|
|
267
271
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
268
272
|
}
|
|
269
273
|
|
|
274
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
275
|
+
// shell flag triggers group-kill so the CLI grandchild dies with the shell.
|
|
276
|
+
const abortWiring = wireAbortToChild(codex, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
277
|
+
|
|
270
278
|
let stdout = '';
|
|
271
279
|
let stderr = '';
|
|
272
280
|
let timeoutId = null;
|
|
@@ -274,10 +282,15 @@ class CodexProvider extends AIProvider {
|
|
|
274
282
|
let lineBuffer = ''; // Buffer for incomplete JSONL lines
|
|
275
283
|
let lineCount = 0; // Count of JSONL events for progress tracking
|
|
276
284
|
|
|
285
|
+
// Centralize detach in `settle` so the abort listener is removed
|
|
286
|
+
// regardless of which exit path (close/timeout/error) wins. Avoids
|
|
287
|
+
// leaking a listener on the per-job AbortSignal that tour/summary
|
|
288
|
+
// generators reuse across many provider.execute() calls.
|
|
277
289
|
const settle = (fn, value) => {
|
|
278
290
|
if (settled) return;
|
|
279
291
|
settled = true;
|
|
280
292
|
if (timeoutId) clearTimeout(timeoutId);
|
|
293
|
+
abortWiring.detach();
|
|
281
294
|
fn(value);
|
|
282
295
|
};
|
|
283
296
|
|
|
@@ -328,11 +341,20 @@ class CodexProvider extends AIProvider {
|
|
|
328
341
|
codex.on('close', (code) => {
|
|
329
342
|
if (settled) return; // Already settled by timeout or error
|
|
330
343
|
|
|
344
|
+
// Detach is centralized in `settle`.
|
|
345
|
+
|
|
331
346
|
// Flush any remaining stream parser buffer
|
|
332
347
|
if (streamParser) {
|
|
333
348
|
streamParser.flush();
|
|
334
349
|
}
|
|
335
350
|
|
|
351
|
+
// BackgroundQueue-driven cancellation — mirror of claude-provider.
|
|
352
|
+
if (abortWiring.cancelled()) {
|
|
353
|
+
logger.info(`${levelPrefix} Codex CLI terminated by user cancel (exit code ${code})`);
|
|
354
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
336
358
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
337
359
|
const isCancellationCode = code === 143 || code === 137;
|
|
338
360
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -407,6 +429,7 @@ class CodexProvider extends AIProvider {
|
|
|
407
429
|
|
|
408
430
|
// Handle errors
|
|
409
431
|
codex.on('error', (error) => {
|
|
432
|
+
// Detach happens inside `settle`.
|
|
410
433
|
if (error.code === 'ENOENT') {
|
|
411
434
|
logger.error(`${levelPrefix} Codex CLI not found. Please ensure Codex CLI is installed.`);
|
|
412
435
|
settle(reject, new Error(`${levelPrefix} Codex CLI not found. ${CodexProvider.getInstallInstructions()}`));
|
|
@@ -510,7 +533,7 @@ class CodexProvider extends AIProvider {
|
|
|
510
533
|
// The accumulated agent_message text contains the AI's response
|
|
511
534
|
// Try to extract JSON from it (the AI was asked to output JSON)
|
|
512
535
|
logger.debug(`${levelPrefix} Extracted ${agentMessageText.length} chars of agent message text from JSONL`);
|
|
513
|
-
const extracted = extractJSON(agentMessageText, level);
|
|
536
|
+
const extracted = extractJSON(agentMessageText, level, levelPrefix);
|
|
514
537
|
if (extracted.success) {
|
|
515
538
|
return extracted;
|
|
516
539
|
}
|
|
@@ -522,12 +545,12 @@ class CodexProvider extends AIProvider {
|
|
|
522
545
|
}
|
|
523
546
|
|
|
524
547
|
// No agent message found, try extracting JSON directly from stdout
|
|
525
|
-
const extracted = extractJSON(stdout, level);
|
|
548
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
526
549
|
return extracted;
|
|
527
550
|
|
|
528
551
|
} catch (parseError) {
|
|
529
552
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
530
|
-
const extracted = extractJSON(stdout, level);
|
|
553
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
531
554
|
if (extracted.success) {
|
|
532
555
|
return extracted;
|
|
533
556
|
}
|
|
@@ -12,6 +12,7 @@ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
|
|
|
12
12
|
const logger = require('../utils/logger');
|
|
13
13
|
const { extractJSON } = require('../utils/json-extractor');
|
|
14
14
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
15
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
15
16
|
|
|
16
17
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
17
18
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -220,7 +221,7 @@ class CopilotProvider extends AIProvider {
|
|
|
220
221
|
return new Promise((resolve, reject) => {
|
|
221
222
|
// Note: Copilot does not support streaming — output is plain text returned on process exit, not JSONL.
|
|
222
223
|
// onStreamEvent is therefore not destructured here (no StreamParser integration).
|
|
223
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, logPrefix } = options;
|
|
224
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, logPrefix, abortSignal } = options;
|
|
224
225
|
|
|
225
226
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
226
227
|
logger.info(`${levelPrefix} Executing Copilot CLI...`);
|
|
@@ -249,7 +250,9 @@ class CopilotProvider extends AIProvider {
|
|
|
249
250
|
...this.extraEnv,
|
|
250
251
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
251
252
|
},
|
|
252
|
-
shell: this.useShell
|
|
253
|
+
shell: this.useShell,
|
|
254
|
+
// Detach in shell mode so group-kill can reap the CLI grandchild.
|
|
255
|
+
detached: this.useShell
|
|
253
256
|
});
|
|
254
257
|
|
|
255
258
|
const pid = copilot.pid;
|
|
@@ -261,15 +264,21 @@ class CopilotProvider extends AIProvider {
|
|
|
261
264
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
262
265
|
}
|
|
263
266
|
|
|
267
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
268
|
+
const abortWiring = wireAbortToChild(copilot, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
269
|
+
|
|
264
270
|
let stdout = '';
|
|
265
271
|
let stderr = '';
|
|
266
272
|
let timeoutId = null;
|
|
267
273
|
let settled = false; // Guard against multiple resolve/reject calls
|
|
268
274
|
|
|
275
|
+
// Detach centralized in settle so timeout-then-close cannot leak
|
|
276
|
+
// an abort listener on the per-job AbortSignal.
|
|
269
277
|
const settle = (fn, value) => {
|
|
270
278
|
if (settled) return;
|
|
271
279
|
settled = true;
|
|
272
280
|
if (timeoutId) clearTimeout(timeoutId);
|
|
281
|
+
abortWiring.detach();
|
|
273
282
|
fn(value);
|
|
274
283
|
};
|
|
275
284
|
|
|
@@ -296,6 +305,15 @@ class CopilotProvider extends AIProvider {
|
|
|
296
305
|
copilot.on('close', (code) => {
|
|
297
306
|
if (settled) return; // Already settled by timeout or error
|
|
298
307
|
|
|
308
|
+
// Detach is centralized in `settle`.
|
|
309
|
+
|
|
310
|
+
// BackgroundQueue-driven cancellation — mirror of claude-provider.
|
|
311
|
+
if (abortWiring.cancelled()) {
|
|
312
|
+
logger.info(`${levelPrefix} Copilot CLI terminated by user cancel (exit code ${code})`);
|
|
313
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
299
317
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
300
318
|
const isCancellationCode = code === 143 || code === 137;
|
|
301
319
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -320,7 +338,7 @@ class CopilotProvider extends AIProvider {
|
|
|
320
338
|
}
|
|
321
339
|
|
|
322
340
|
// Extract JSON from the response
|
|
323
|
-
const extracted = extractJSON(stdout, level);
|
|
341
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
324
342
|
if (extracted.success) {
|
|
325
343
|
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
326
344
|
settle(resolve, extracted.data);
|
|
@@ -352,6 +370,7 @@ class CopilotProvider extends AIProvider {
|
|
|
352
370
|
|
|
353
371
|
// Handle errors
|
|
354
372
|
copilot.on('error', (error) => {
|
|
373
|
+
// Detach happens inside `settle`.
|
|
355
374
|
if (error.code === 'ENOENT') {
|
|
356
375
|
logger.error(`${levelPrefix} Copilot CLI not found. Please ensure Copilot CLI is installed.`);
|
|
357
376
|
settle(reject, new Error(`${levelPrefix} Copilot CLI not found. ${CopilotProvider.getInstallInstructions()}`));
|