@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/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
+ }
@@ -0,0 +1,4 @@
1
+ # @banbolee/dsh-lsp-diagnostics bundle: register the host-plane plugin row
2
+ - insert:
3
+ - id: lsp-diagnostics
4
+ name: '@banbolee/dsh-lsp-diagnostics'