@banbolee/dsh-lsp-diagnostics 0.1.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 +297 -0
- package/collector.js +220 -0
- package/coordinator.js +686 -0
- package/cordis.patch.yml +4 -0
- package/framing.js +170 -0
- package/index.js +581 -0
- package/package.json +70 -0
- package/render.js +468 -0
- package/runtime.js +1613 -0
- package/tool.js +496 -0
package/coordinator.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics coordinator: owns the post-execute augment transaction, the
|
|
3
|
+
* operation registry/controller, the absolute-deadline timer with its
|
|
4
|
+
* caller/cleanup relay, the one-shot final stat/deadline/generation commit
|
|
5
|
+
* gate, and the `retiredIo` late-final-stat registry.
|
|
6
|
+
*
|
|
7
|
+
* The coordinator is the single owner of the whole augment lifecycle: every
|
|
8
|
+
* augment is registered in the active registry before any workspace I/O or
|
|
9
|
+
* runtime admission, every terminal path goes through the one-shot gate and
|
|
10
|
+
* retires its taken candidates, and unload strictly runs stop-admission →
|
|
11
|
+
* abort active operations → await all active augment promises → await all
|
|
12
|
+
* retired late-final-stat I/O, with the entry applying `runtime.dispose()`
|
|
13
|
+
* only after both registries are empty.
|
|
14
|
+
*
|
|
15
|
+
* @module @banbolee/dsh-lsp-diagnostics/coordinator
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
19
|
+
import { compareEligibleTargets, renderDiagnostics, sanitizeDisplayPath } from './render.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Loose structural seams, mirroring the runtime's own public shapes. The
|
|
23
|
+
* coordinator only consumes the documented surface (`take/isCurrent/
|
|
24
|
+
* retireIfCurrent`, `diagnose/stopAdmission/dispose`, `resolve/stat/
|
|
25
|
+
* contains/fileUrl`); keeping these structural keeps both the real services
|
|
26
|
+
* and the deterministic test fakes assignable.
|
|
27
|
+
* @typedef {object} CollectorSeam
|
|
28
|
+
* @property {(exec: any, target: any, observation: any) => void} observe
|
|
29
|
+
* @property {(exec: any) => import('./collector.js').MutationCandidate[]} take
|
|
30
|
+
* @property {(candidate: any) => boolean} isCurrent
|
|
31
|
+
* @property {(candidate: any) => boolean} retireIfCurrent
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} RuntimeSeam
|
|
36
|
+
* @property {(candidate: any, canonicalWorkspace: any, canonicalUri: string, signal?: AbortSignal) => Promise<import('./runtime.js').DiagnosisOutcome>} diagnose
|
|
37
|
+
* @property {() => void} stopAdmission
|
|
38
|
+
* @property {() => Promise<void>} dispose
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} FsSeam
|
|
43
|
+
* @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<any>} resolve
|
|
44
|
+
* @property {(target: any, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
|
|
45
|
+
* @property {(parent: any, child: any) => boolean} contains
|
|
46
|
+
* @property {(target: any) => string} fileUrl
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {object} CoordinatorOptions
|
|
51
|
+
* @property {CollectorSeam} collector
|
|
52
|
+
* @property {RuntimeSeam} runtime
|
|
53
|
+
* @property {import('./index.js').PluginConfig} config
|
|
54
|
+
* @property {FsSeam} fs
|
|
55
|
+
* @property {() => number} [now] - monotonic clock (defaults to
|
|
56
|
+
* `performance.now()`); the absolute deadline and every gate check read it,
|
|
57
|
+
* never `Date.now()`.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {object} AugmentOperation
|
|
62
|
+
* @property {AbortController} controller
|
|
63
|
+
* @property {Promise<unknown>} promise
|
|
64
|
+
* @property {() => void} expireDeadline
|
|
65
|
+
* @property {() => void} disposeOperationDeadline
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {object} FinalStatRecord
|
|
70
|
+
* @property {boolean} settled
|
|
71
|
+
* @property {{ status: 'fulfilled', value: { version: string, type: string, size?: number } | undefined } | { status: 'rejected', reason: unknown } | undefined} outcome
|
|
72
|
+
* @property {Promise<{ status: 'fulfilled' | 'rejected', value?: any, reason?: unknown }>} promise
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** @typedef {'open' | 'committed' | 'timed-out'} GateState */
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The accepted post-execute decision surface this plugin reads and extends.
|
|
79
|
+
* @typedef {object} PostExecuteDecision
|
|
80
|
+
* @property {string} kind
|
|
81
|
+
* @property {readonly unknown[]} [additionalContexts]
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Lowercase last path suffix of a file URL's pathname (`.ts`, `.tsx`, `.go`).
|
|
86
|
+
* Never reads `displayPath` for routing.
|
|
87
|
+
* @param {string} uri - the canonical document URI.
|
|
88
|
+
* @returns {string} the extension including the dot, or '' when absent.
|
|
89
|
+
*/
|
|
90
|
+
function extensionOf(uri) {
|
|
91
|
+
try {
|
|
92
|
+
const pathname = new URL(uri).pathname
|
|
93
|
+
const slash = pathname.lastIndexOf('/')
|
|
94
|
+
const base = slash === -1 ? pathname : pathname.slice(slash + 1)
|
|
95
|
+
const dot = base.lastIndexOf('.')
|
|
96
|
+
if (dot === -1) return ''
|
|
97
|
+
return base.slice(dot).toLowerCase()
|
|
98
|
+
} catch {
|
|
99
|
+
return ''
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create the diagnostics coordinator.
|
|
105
|
+
*
|
|
106
|
+
* @param {CoordinatorOptions} options - collector/runtime/config/fs dependencies.
|
|
107
|
+
* @returns {{
|
|
108
|
+
* listener(exec: unknown, _result: unknown, next: () => Promise<unknown>): Promise<unknown>
|
|
109
|
+
* stopAdmission(): void
|
|
110
|
+
* abortActiveOperations(): void
|
|
111
|
+
* awaitActiveOperations(): Promise<void>
|
|
112
|
+
* awaitRetiredIo(): Promise<void>
|
|
113
|
+
* }}
|
|
114
|
+
*/
|
|
115
|
+
export function createDiagnosticsCoordinator({ collector, runtime, config, fs, now = () => performance.now() }) {
|
|
116
|
+
/** @type {Set<AugmentOperation>} */
|
|
117
|
+
const activeOperations = new Set()
|
|
118
|
+
/** @type {Set<Promise<unknown>>} */
|
|
119
|
+
const pendingInvocations = new Set()
|
|
120
|
+
/** @type {Set<Promise<unknown>>} */
|
|
121
|
+
const retiredIo = new Set()
|
|
122
|
+
/** @type {AbortController} */
|
|
123
|
+
const cleanupController = new AbortController()
|
|
124
|
+
/** @type {boolean} */
|
|
125
|
+
let admissionOpen = true
|
|
126
|
+
|
|
127
|
+
/** Closed extension route derived from the validated server config. */
|
|
128
|
+
const supportedExtensions = new Set()
|
|
129
|
+
for (const server of Object.values(config.servers)) {
|
|
130
|
+
for (const extension of Object.keys(server.extensionToLanguage)) supportedExtensions.add(extension)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Synchronously close admission and stop the runtime's own admission.
|
|
135
|
+
* Deliberately does NOT abort the coordinator cleanup signal: the documented
|
|
136
|
+
* lifecycle aborts active operations only at the later `abortActiveOperations`
|
|
137
|
+
* step, after both listener disposers have run. Idempotent.
|
|
138
|
+
* @returns {void}
|
|
139
|
+
*/
|
|
140
|
+
function stopAdmission() {
|
|
141
|
+
admissionOpen = false
|
|
142
|
+
runtime.stopAdmission()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Abort the coordinator cleanup signal (relaying the cleanup abort to every
|
|
147
|
+
* registered operation, which records the abort kind and retires unsettled
|
|
148
|
+
* final stats) and abort every active augment operation controller. A
|
|
149
|
+
* listener still awaiting downstream `next()` has no plugin-owned
|
|
150
|
+
* cancellation seam, so cleanup owns it by waiting for its tracked
|
|
151
|
+
* settlement instead. Idempotent.
|
|
152
|
+
* @returns {void}
|
|
153
|
+
*/
|
|
154
|
+
function abortActiveOperations() {
|
|
155
|
+
cleanupController.abort()
|
|
156
|
+
for (const operation of activeOperations) {
|
|
157
|
+
if (!operation.controller.signal.aborted) operation.controller.abort()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Await settlement of every active augment and every pending listener
|
|
163
|
+
* invocation, looping until both registries are empty (each tracked promise
|
|
164
|
+
* removes itself in its outermost finally).
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
async function awaitActiveOperations() {
|
|
168
|
+
while (activeOperations.size > 0 || pendingInvocations.size > 0) {
|
|
169
|
+
const tracked = [
|
|
170
|
+
...[...activeOperations].map((operation) => operation.promise),
|
|
171
|
+
...pendingInvocations,
|
|
172
|
+
]
|
|
173
|
+
await Promise.allSettled(tracked)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Await settlement of every retired late-final-stat record, looping until
|
|
179
|
+
* the registry is empty (each record removes itself in its finalize).
|
|
180
|
+
* @returns {Promise<void>}
|
|
181
|
+
*/
|
|
182
|
+
async function awaitRetiredIo() {
|
|
183
|
+
while (retiredIo.size > 0) {
|
|
184
|
+
await Promise.allSettled([...retiredIo])
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Register every not-yet-settled final-stat record's promise in `retiredIo`.
|
|
190
|
+
* Synchronous, idempotent, never awaits. Late settlement only has the
|
|
191
|
+
* record's observer/removal side effects.
|
|
192
|
+
* @param {readonly FinalStatRecord[]} records - the taken final-stat records.
|
|
193
|
+
* @returns {void}
|
|
194
|
+
*/
|
|
195
|
+
function retireUnsettledFinalStats(records) {
|
|
196
|
+
for (const record of records) {
|
|
197
|
+
if (!record.settled) retiredIo.add(record.promise)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Start one always-observed final stat record. The chain always settles to
|
|
203
|
+
* `{ status: 'fulfilled' | 'rejected' }`, captures its settled outcome on
|
|
204
|
+
* the record, and its non-throwing finalize synchronously marks it settled
|
|
205
|
+
* and idempotently removes its promise from `retiredIo`.
|
|
206
|
+
* @param {any} target - the document target.
|
|
207
|
+
* @param {AbortSignal | undefined} signal - the operation signal.
|
|
208
|
+
* @returns {FinalStatRecord}
|
|
209
|
+
*/
|
|
210
|
+
function startFinalStat(target, signal) {
|
|
211
|
+
/** @type {FinalStatRecord} */
|
|
212
|
+
const record = {
|
|
213
|
+
settled: false,
|
|
214
|
+
outcome: undefined,
|
|
215
|
+
promise: /** @type {FinalStatRecord['promise']} */ (/** @type {unknown} */ (Promise.resolve(undefined))),
|
|
216
|
+
}
|
|
217
|
+
const promise = Promise.resolve()
|
|
218
|
+
.then(() => fs.stat(target, signal))
|
|
219
|
+
.then((value) => {
|
|
220
|
+
const outcome = /** @type {{ status: 'fulfilled', value: { version: string, type: string, size?: number } | undefined }} */ ({
|
|
221
|
+
status: 'fulfilled',
|
|
222
|
+
value,
|
|
223
|
+
})
|
|
224
|
+
record.outcome = outcome
|
|
225
|
+
return outcome
|
|
226
|
+
})
|
|
227
|
+
.catch((reason) => {
|
|
228
|
+
const outcome = /** @type {{ status: 'rejected', reason: unknown }} */ ({ status: 'rejected', reason })
|
|
229
|
+
record.outcome = outcome
|
|
230
|
+
return outcome
|
|
231
|
+
})
|
|
232
|
+
.finally(() => {
|
|
233
|
+
record.settled = true
|
|
234
|
+
retiredIo.delete(promise)
|
|
235
|
+
})
|
|
236
|
+
record.promise = promise
|
|
237
|
+
return record
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Begin one post-execute augment transaction. In a no-await critical
|
|
242
|
+
* section: when admission is closed, take + retire synchronously and return
|
|
243
|
+
* undefined (zero timers/listeners/I/O). Otherwise create the controller,
|
|
244
|
+
* the absolute-deadline timer (from the injected monotonic clock), the
|
|
245
|
+
* caller/cleanup relay listeners and the idempotent disposer, register the
|
|
246
|
+
* tracked promise in the active registry BEFORE any workspace I/O (the body
|
|
247
|
+
* starts in a microtask), and return the operation record.
|
|
248
|
+
* @param {unknown} exec - the tool-execution context.
|
|
249
|
+
* @param {unknown} decision - the already-accepted post-execute decision.
|
|
250
|
+
* @returns {AugmentOperation | undefined}
|
|
251
|
+
*/
|
|
252
|
+
function beginAugment(exec, decision) {
|
|
253
|
+
if (!admissionOpen) {
|
|
254
|
+
const candidates = collector.take(exec)
|
|
255
|
+
for (const candidate of candidates) collector.retireIfCurrent(candidate)
|
|
256
|
+
return undefined
|
|
257
|
+
}
|
|
258
|
+
const controller = new AbortController()
|
|
259
|
+
const deadlineAt = now() + config.timeoutMs
|
|
260
|
+
/** @type {{ gate: GateState, deadlineFired: boolean, abortKind: 'caller' | 'cleanup' | undefined, finalStats: FinalStatRecord[] }} */
|
|
261
|
+
const state = {
|
|
262
|
+
gate: 'open',
|
|
263
|
+
deadlineFired: false,
|
|
264
|
+
abortKind: undefined,
|
|
265
|
+
finalStats: [],
|
|
266
|
+
}
|
|
267
|
+
/** @type {AugmentOperation} */
|
|
268
|
+
const operation = {
|
|
269
|
+
controller,
|
|
270
|
+
promise: /** @type {Promise<unknown>} */ (Promise.resolve(undefined)),
|
|
271
|
+
expireDeadline: () => {},
|
|
272
|
+
disposeOperationDeadline: () => {},
|
|
273
|
+
}
|
|
274
|
+
const transitionTimeout = () => {
|
|
275
|
+
if (state.gate !== 'open') return
|
|
276
|
+
state.gate = 'timed-out'
|
|
277
|
+
retireUnsettledFinalStats(state.finalStats)
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Record the abort source and run the deadline transition. The abort kind
|
|
281
|
+
* is recorded even when the controller was already aborted (e.g. the
|
|
282
|
+
* deadline fired first and a caller/cleanup abort arrives later), so a
|
|
283
|
+
* later external abort always suppresses the timeout publication.
|
|
284
|
+
* @param {'deadline' | 'caller' | 'cleanup'} reason - the abort source.
|
|
285
|
+
*/
|
|
286
|
+
const abortWith = (reason) => {
|
|
287
|
+
if (reason !== 'deadline' && state.abortKind === undefined) state.abortKind = reason
|
|
288
|
+
if (controller.signal.aborted) return
|
|
289
|
+
if (reason === 'deadline') state.deadlineFired = true
|
|
290
|
+
controller.abort()
|
|
291
|
+
transitionTimeout()
|
|
292
|
+
}
|
|
293
|
+
const callerSignal = /** @type {{ signal?: AbortSignal } | null} */ (exec)?.signal
|
|
294
|
+
const onCallerAbort = () => abortWith('caller')
|
|
295
|
+
const onCleanupAbort = () => abortWith('cleanup')
|
|
296
|
+
operation.expireDeadline = () => abortWith('deadline')
|
|
297
|
+
const onDeadline = operation.expireDeadline
|
|
298
|
+
if (callerSignal instanceof AbortSignal) {
|
|
299
|
+
if (callerSignal.aborted) onCallerAbort()
|
|
300
|
+
else callerSignal.addEventListener('abort', onCallerAbort, { once: true })
|
|
301
|
+
}
|
|
302
|
+
cleanupController.signal.addEventListener('abort', onCleanupAbort, { once: true })
|
|
303
|
+
const deadlineTimer = setTimeout(onDeadline, config.timeoutMs)
|
|
304
|
+
let disposed = false
|
|
305
|
+
const disposeOperationDeadline = () => {
|
|
306
|
+
if (disposed) return
|
|
307
|
+
disposed = true
|
|
308
|
+
clearTimeout(deadlineTimer)
|
|
309
|
+
if (callerSignal instanceof AbortSignal) callerSignal.removeEventListener('abort', onCallerAbort)
|
|
310
|
+
cleanupController.signal.removeEventListener('abort', onCleanupAbort)
|
|
311
|
+
}
|
|
312
|
+
operation.disposeOperationDeadline = disposeOperationDeadline
|
|
313
|
+
const tracked = Promise.resolve()
|
|
314
|
+
.then(() => augmentAcceptedDecision(exec, decision, operation, state, deadlineAt))
|
|
315
|
+
.finally(() => {
|
|
316
|
+
// Non-standard termination: if final stats started but the gate never
|
|
317
|
+
// closed stats-first, abort, forbid publish, and re-register unsettled
|
|
318
|
+
// records before disposing the timer/listeners and dropping the record.
|
|
319
|
+
if (state.finalStats.length > 0 && state.gate === 'open') {
|
|
320
|
+
abortWith('cleanup')
|
|
321
|
+
}
|
|
322
|
+
operation.disposeOperationDeadline()
|
|
323
|
+
activeOperations.delete(operation)
|
|
324
|
+
})
|
|
325
|
+
operation.promise = tracked
|
|
326
|
+
activeOperations.add(operation)
|
|
327
|
+
return operation
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Run the accepted-decision augment: take, extension route, workspace
|
|
332
|
+
* eligibility, frozen render metadata, serial diagnosis, the one-shot final
|
|
333
|
+
* stat/deadline/generation gate, the aggregate render, and the single
|
|
334
|
+
* context commit. Every return path takes and retires.
|
|
335
|
+
* @param {unknown} exec - the tool-execution context.
|
|
336
|
+
* @param {unknown} decision - the accepted post-execute decision.
|
|
337
|
+
* @param {AugmentOperation} operation - the registered operation record.
|
|
338
|
+
* @param {{ gate: GateState, deadlineFired: boolean, abortKind: 'caller' | 'cleanup' | undefined, finalStats: FinalStatRecord[] }} state - shared gate state.
|
|
339
|
+
* @param {number} deadlineAt - absolute deadline timestamp.
|
|
340
|
+
* @returns {Promise<unknown>} the decision, possibly with the notice appended.
|
|
341
|
+
*/
|
|
342
|
+
async function augmentAcceptedDecision(exec, decision, operation, state, deadlineAt) {
|
|
343
|
+
const signal = operation.controller.signal
|
|
344
|
+
const candidates = collector.take(exec)
|
|
345
|
+
/** Retire every taken candidate; idempotent and safe on every return path. */
|
|
346
|
+
const retireAll = () => {
|
|
347
|
+
for (const candidate of candidates) collector.retireIfCurrent(candidate)
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
// A pre-aborted operation (caller/cleanup abort won before this microtask
|
|
351
|
+
// started) must retire synchronously with zero workspace I/O — not even
|
|
352
|
+
// the canonical-URI derivation — and must never publish.
|
|
353
|
+
if (signal.aborted) {
|
|
354
|
+
retireAll()
|
|
355
|
+
return decision
|
|
356
|
+
}
|
|
357
|
+
/** @type {PostExecuteDecision} */
|
|
358
|
+
const decisionRecord = /** @type {PostExecuteDecision} */ (decision)
|
|
359
|
+
if (decisionRecord.kind !== 'accept') {
|
|
360
|
+
retireAll()
|
|
361
|
+
return decision
|
|
362
|
+
}
|
|
363
|
+
// 1. Extension route filter, before any workspace/runtime/read work.
|
|
364
|
+
/** @type {{ candidate: import('./collector.js').MutationCandidate, canonicalUri: string }[]} */
|
|
365
|
+
const routed = []
|
|
366
|
+
for (const candidate of candidates) {
|
|
367
|
+
let canonicalUri
|
|
368
|
+
try {
|
|
369
|
+
canonicalUri = fs.fileUrl(candidate.target)
|
|
370
|
+
} catch {
|
|
371
|
+
collector.retireIfCurrent(candidate)
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
if (supportedExtensions.has(extensionOf(canonicalUri))) routed.push({ candidate, canonicalUri })
|
|
375
|
+
else collector.retireIfCurrent(candidate)
|
|
376
|
+
}
|
|
377
|
+
if (routed.length === 0) {
|
|
378
|
+
retireAll()
|
|
379
|
+
return decision
|
|
380
|
+
}
|
|
381
|
+
if (signal.aborted) {
|
|
382
|
+
retireAll()
|
|
383
|
+
return decision
|
|
384
|
+
}
|
|
385
|
+
// 2. Workspace root from the session header; missing/empty is ineligible.
|
|
386
|
+
const execRecord = /** @type {{ agent?: { session?: { header?: { cwd?: unknown } } } }} */ (exec)
|
|
387
|
+
const workspaceRoot = execRecord?.agent?.session?.header?.cwd
|
|
388
|
+
if (typeof workspaceRoot !== 'string' || workspaceRoot.length === 0) {
|
|
389
|
+
retireAll()
|
|
390
|
+
return decision
|
|
391
|
+
}
|
|
392
|
+
if (signal.aborted) {
|
|
393
|
+
retireAll()
|
|
394
|
+
return decision
|
|
395
|
+
}
|
|
396
|
+
// 3. Canonicalize the workspace exactly once.
|
|
397
|
+
let workspaceTarget
|
|
398
|
+
try {
|
|
399
|
+
workspaceTarget = await fs.resolve(workspaceRoot, { signal })
|
|
400
|
+
} catch {
|
|
401
|
+
retireAll()
|
|
402
|
+
return decision
|
|
403
|
+
}
|
|
404
|
+
if (signal.aborted) {
|
|
405
|
+
retireAll()
|
|
406
|
+
return decision
|
|
407
|
+
}
|
|
408
|
+
let workspaceInfo
|
|
409
|
+
try {
|
|
410
|
+
workspaceInfo = await fs.stat(workspaceTarget, signal)
|
|
411
|
+
} catch {
|
|
412
|
+
retireAll()
|
|
413
|
+
return decision
|
|
414
|
+
}
|
|
415
|
+
if (signal.aborted) {
|
|
416
|
+
retireAll()
|
|
417
|
+
return decision
|
|
418
|
+
}
|
|
419
|
+
if (workspaceInfo === undefined || workspaceInfo.type !== 'directory') {
|
|
420
|
+
retireAll()
|
|
421
|
+
return decision
|
|
422
|
+
}
|
|
423
|
+
// 4. Per-target eligibility: contains with pre/post abort checks, then
|
|
424
|
+
// freeze renderPath + canonicalUri exactly once. Every early exit and
|
|
425
|
+
// abort break retires the remaining taken candidates.
|
|
426
|
+
/** @type {{ candidate: import('./collector.js').MutationCandidate, renderPath: string, targetKey: string, canonicalUri: string }[]} */
|
|
427
|
+
const eligible = []
|
|
428
|
+
for (const route of routed) {
|
|
429
|
+
const { candidate, canonicalUri } = route
|
|
430
|
+
if (signal.aborted) break
|
|
431
|
+
let contained = false
|
|
432
|
+
try {
|
|
433
|
+
contained = fs.contains(workspaceTarget, candidate.target) === true
|
|
434
|
+
} catch {
|
|
435
|
+
contained = false
|
|
436
|
+
}
|
|
437
|
+
if (signal.aborted) break
|
|
438
|
+
if (!contained) {
|
|
439
|
+
collector.retireIfCurrent(candidate)
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
let renderPath
|
|
443
|
+
try {
|
|
444
|
+
renderPath = sanitizeDisplayPath(candidate.target.displayPath)
|
|
445
|
+
} catch {
|
|
446
|
+
collector.retireIfCurrent(candidate)
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
eligible.push({
|
|
450
|
+
candidate,
|
|
451
|
+
renderPath,
|
|
452
|
+
targetKey: String(candidate.target.targetKey ?? ''),
|
|
453
|
+
canonicalUri,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
if (signal.aborted) {
|
|
457
|
+
retireAll()
|
|
458
|
+
return decision
|
|
459
|
+
}
|
|
460
|
+
if (eligible.length === 0) {
|
|
461
|
+
retireAll()
|
|
462
|
+
return decision
|
|
463
|
+
}
|
|
464
|
+
eligible.sort(compareEligibleTargets)
|
|
465
|
+
// 5. Serial diagnosis in the shared order.
|
|
466
|
+
/** @type {{ target: (typeof eligible)[number], outcome: import('./runtime.js').DiagnosisOutcome }[]} */
|
|
467
|
+
const diagnosed = []
|
|
468
|
+
for (const target of eligible) {
|
|
469
|
+
if (signal.aborted) break
|
|
470
|
+
/** @type {import('./runtime.js').DiagnosisOutcome} */
|
|
471
|
+
let outcome
|
|
472
|
+
try {
|
|
473
|
+
outcome = await runtime.diagnose(target.candidate, workspaceTarget, target.canonicalUri, signal)
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (signal.aborted) break
|
|
476
|
+
// Plugin-owned failure after eligibility: bounded unavailable with
|
|
477
|
+
// safe candidate metadata.
|
|
478
|
+
outcome = { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
479
|
+
}
|
|
480
|
+
if (signal.aborted) break
|
|
481
|
+
diagnosed.push({ target, outcome })
|
|
482
|
+
}
|
|
483
|
+
// 6. Final stat round + one-shot commit gate. Only candidates that
|
|
484
|
+
// actually completed a diagnosis get a final stat, and only while the
|
|
485
|
+
// gate is still open and the operation is alive.
|
|
486
|
+
/** @type {FinalStatRecord[]} */
|
|
487
|
+
const records = []
|
|
488
|
+
if (state.gate === 'open' && !signal.aborted && now() < deadlineAt) {
|
|
489
|
+
for (const { target } of diagnosed) records.push(startFinalStat(target.candidate.target, signal))
|
|
490
|
+
state.finalStats = records
|
|
491
|
+
if (records.length > 0) {
|
|
492
|
+
const allStats = Promise.all(records.map((record) => record.promise))
|
|
493
|
+
let onAbort
|
|
494
|
+
const aborted = new Promise((resolve) => {
|
|
495
|
+
if (signal.aborted) {
|
|
496
|
+
resolve(true)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
onAbort = () => resolve(true)
|
|
500
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
501
|
+
})
|
|
502
|
+
try {
|
|
503
|
+
await Promise.race([allStats, aborted])
|
|
504
|
+
} finally {
|
|
505
|
+
if (onAbort !== undefined) signal.removeEventListener('abort', onAbort)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// The gate's terminal decision, in the same no-await critical section:
|
|
510
|
+
// stats-first commits only when every record settled, the clock is
|
|
511
|
+
// strictly before the deadline, the signal is alive, and the gate is
|
|
512
|
+
// still open; `now === deadlineAt` is always deadline-first.
|
|
513
|
+
if (state.gate === 'open') {
|
|
514
|
+
const allSettled = records.length > 0 && records.every((record) => record.settled)
|
|
515
|
+
if (allSettled && now() < deadlineAt && !signal.aborted) {
|
|
516
|
+
state.gate = 'committed'
|
|
517
|
+
} else {
|
|
518
|
+
// Monotonic-clock expiry before the deadline timer task runs: run the
|
|
519
|
+
// full deadline transition (deadlineFired + controller abort) so the
|
|
520
|
+
// same-tick contender still produces the timeout aggregate.
|
|
521
|
+
if (!signal.aborted && now() >= deadlineAt) operation.expireDeadline()
|
|
522
|
+
else {
|
|
523
|
+
state.gate = 'timed-out'
|
|
524
|
+
retireUnsettledFinalStats(records)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// 7. Build the renderer entries according to the winning gate.
|
|
529
|
+
/** @type {{ renderPath: string, targetKey: string, canonicalUri: string, kind: 'diagnostics' | 'clean' | 'unavailable', diagnostics?: readonly import('./render.js').NormalizedDiagnostic[], reason?: string }[]} */
|
|
530
|
+
const entries = []
|
|
531
|
+
if (state.gate === 'committed') {
|
|
532
|
+
for (let index = 0; index < diagnosed.length; index += 1) {
|
|
533
|
+
const entry = diagnosed[index]
|
|
534
|
+
if (entry === undefined) continue
|
|
535
|
+
const { target, outcome } = entry
|
|
536
|
+
const record = records[index]
|
|
537
|
+
if (record === undefined) continue
|
|
538
|
+
if (record.outcome === undefined || record.outcome.status === 'rejected') {
|
|
539
|
+
// Stat plugin-owned error: bounded unavailable only while current.
|
|
540
|
+
if (collector.isCurrent(target.candidate)) {
|
|
541
|
+
entries.push({
|
|
542
|
+
renderPath: target.renderPath,
|
|
543
|
+
targetKey: target.targetKey,
|
|
544
|
+
canonicalUri: target.canonicalUri,
|
|
545
|
+
kind: 'unavailable',
|
|
546
|
+
reason: 'diagnostics unavailable',
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
const info = record.outcome.value
|
|
552
|
+
if (info === undefined || info.type !== 'file') continue
|
|
553
|
+
if (info.version !== target.candidate.version) continue
|
|
554
|
+
if (!collector.isCurrent(target.candidate)) continue
|
|
555
|
+
if (outcome.kind === 'stale') continue
|
|
556
|
+
if (outcome.kind === 'ok') {
|
|
557
|
+
if (outcome.diagnostics.length === 0) {
|
|
558
|
+
entries.push({
|
|
559
|
+
renderPath: target.renderPath,
|
|
560
|
+
targetKey: target.targetKey,
|
|
561
|
+
canonicalUri: target.canonicalUri,
|
|
562
|
+
kind: 'clean',
|
|
563
|
+
})
|
|
564
|
+
} else {
|
|
565
|
+
entries.push({
|
|
566
|
+
renderPath: target.renderPath,
|
|
567
|
+
targetKey: target.targetKey,
|
|
568
|
+
canonicalUri: target.canonicalUri,
|
|
569
|
+
kind: 'diagnostics',
|
|
570
|
+
diagnostics: outcome.diagnostics,
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
entries.push({
|
|
575
|
+
renderPath: target.renderPath,
|
|
576
|
+
targetKey: target.targetKey,
|
|
577
|
+
canonicalUri: target.canonicalUri,
|
|
578
|
+
kind: 'unavailable',
|
|
579
|
+
reason: outcome.reason,
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
} else if (state.gate === 'timed-out' && state.deadlineFired && state.abortKind === undefined) {
|
|
584
|
+
// Deadline-first: timeout unavailable only for still-current eligible
|
|
585
|
+
// candidates; cleanup/caller abort never generates context.
|
|
586
|
+
for (const target of eligible) {
|
|
587
|
+
if (collector.isCurrent(target.candidate)) {
|
|
588
|
+
entries.push({
|
|
589
|
+
renderPath: target.renderPath,
|
|
590
|
+
targetKey: target.targetKey,
|
|
591
|
+
canonicalUri: target.canonicalUri,
|
|
592
|
+
kind: 'unavailable',
|
|
593
|
+
reason: 'timeout',
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Both terminal paths retire every taken candidate in this same
|
|
599
|
+
// no-await critical section; never touch the independent counter or a
|
|
600
|
+
// newer active marker.
|
|
601
|
+
retireAll()
|
|
602
|
+
// 8. Commit exactly one aggregate context when the gate won and the
|
|
603
|
+
// operation was not aborted by the caller or by cleanup. Deadline-first
|
|
604
|
+
// (gate timed-out + deadline fired, no external abort kind) still
|
|
605
|
+
// publishes its timeout-unavailable aggregate; caller/cleanup abort
|
|
606
|
+
// never generates context.
|
|
607
|
+
if (entries.length > 0 && state.abortKind === undefined) {
|
|
608
|
+
const rendered = renderDiagnostics(entries, config)
|
|
609
|
+
if (rendered.text !== null) {
|
|
610
|
+
const message = createUserMessage({
|
|
611
|
+
content: [{ type: 'text', text: rendered.text }],
|
|
612
|
+
source: {
|
|
613
|
+
kind: 'plugin',
|
|
614
|
+
plugin: '@banbolee/dsh-lsp-diagnostics',
|
|
615
|
+
form: 'notice',
|
|
616
|
+
summary: boundContextSummary(rendered.text),
|
|
617
|
+
},
|
|
618
|
+
})
|
|
619
|
+
return {
|
|
620
|
+
...decisionRecord,
|
|
621
|
+
additionalContexts: [...(decisionRecord.additionalContexts ?? []), message],
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return decision
|
|
626
|
+
} catch (error) {
|
|
627
|
+
// Plugin-owned catch: fail-open with the original decision; the outer
|
|
628
|
+
// tracked finally still disposes the timer/listeners and the record.
|
|
629
|
+
for (const candidate of candidates) collector.retireIfCurrent(candidate)
|
|
630
|
+
return decision
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The real three-parameter `tools/post-execute` waterfall listener:
|
|
636
|
+
* `await next()` is the unique call and sits outside any try/catch, so
|
|
637
|
+
* downstream throws and caller aborts propagate with their identity. The
|
|
638
|
+
* invocation is tracked before `next()` so plugin cleanup owns and awaits
|
|
639
|
+
* its settlement, and a downstream rejection still takes and retires the exec's
|
|
640
|
+
* candidates before the original error propagates.
|
|
641
|
+
* @param {unknown} exec - the tool-execution context.
|
|
642
|
+
* @param {unknown} _result - the dispatched result (unconsumed, but never omitted).
|
|
643
|
+
* @param {() => Promise<unknown>} next - the waterfall continuation.
|
|
644
|
+
* @returns {Promise<unknown>}
|
|
645
|
+
*/
|
|
646
|
+
const listener = (exec, _result, next) => {
|
|
647
|
+
// Start in a microtask so the complete listener promise is registered
|
|
648
|
+
// before `next()` can run or perform any downstream work.
|
|
649
|
+
const invocation = Promise.resolve().then(async () => {
|
|
650
|
+
// Cordis's waterfall invokes downstream listeners directly, so a
|
|
651
|
+
// non-async listener can throw synchronously out of `next()`. Both the
|
|
652
|
+
// synchronous throw and a rejected promise must retire the exec's
|
|
653
|
+
// candidates before the original error propagates.
|
|
654
|
+
/** @type {Promise<unknown>} */
|
|
655
|
+
let downstream
|
|
656
|
+
try {
|
|
657
|
+
downstream = next()
|
|
658
|
+
} catch (error) {
|
|
659
|
+
for (const candidate of collector.take(exec)) collector.retireIfCurrent(candidate)
|
|
660
|
+
throw error
|
|
661
|
+
}
|
|
662
|
+
// Observe rejection only to clean collector state. Awaiting the original
|
|
663
|
+
// promise below preserves the exact rejection object and remains outside
|
|
664
|
+
// the plugin-owned try/catch.
|
|
665
|
+
void downstream.then(undefined, () => {
|
|
666
|
+
for (const candidate of collector.take(exec)) collector.retireIfCurrent(candidate)
|
|
667
|
+
})
|
|
668
|
+
const decision = await downstream
|
|
669
|
+
try {
|
|
670
|
+
const operation = beginAugment(exec, decision)
|
|
671
|
+
if (operation === undefined) return decision
|
|
672
|
+
return await operation.promise
|
|
673
|
+
} catch (_pluginOwnedFailure) {
|
|
674
|
+
return decision
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
pendingInvocations.add(invocation)
|
|
678
|
+
void invocation.then(
|
|
679
|
+
() => pendingInvocations.delete(invocation),
|
|
680
|
+
() => pendingInvocations.delete(invocation),
|
|
681
|
+
)
|
|
682
|
+
return invocation
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return { listener, stopAdmission, abortActiveOperations, awaitActiveOperations, awaitRetiredIo }
|
|
686
|
+
}
|
package/cordis.patch.yml
ADDED