@vue-skuilder/db 0.1.34 → 0.1.36

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.34",
7
+ "version": "0.1.36",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.1.34",
51
+ "@vue-skuilder/common": "0.1.36",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^8.0.0",
63
63
  "vitest": "^4.1.0"
64
64
  },
65
- "stableVersion": "0.1.34"
65
+ "stableVersion": "0.1.36"
66
66
  }
@@ -524,8 +524,11 @@ export class Pipeline extends ContentNavigator {
524
524
  // Capture run for debug API
525
525
  try {
526
526
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => undefined);
527
- // Use the full post-filter sorted array (not just top N) so that
528
- // showCard() can inspect provenance for cards that didn't make the cut.
527
+ // Pass the full post-filter sorted array; buildRunReport retains all
528
+ // selected cards plus the top-N highest-scoring near-misses and
529
+ // summarizes the discarded tail (see DISCARDED_KEEP_TOP). This keeps
530
+ // showCard() useful for "why didn't this rank?" inspection without
531
+ // pinning hundreds of low-score candidates' provenance per run.
529
532
  // `cards` is the post-filter, post-hints, sorted array.
530
533
  const report = buildRunReport(
531
534
  this.course?.getCourseID() || 'unknown',
@@ -92,7 +92,19 @@ export interface PipelineRunReport {
92
92
  reviewsSelected: number;
93
93
  newSelected: number;
94
94
 
95
- // Full card data for inspection
95
+ /**
96
+ * Card data for inspection.
97
+ *
98
+ * To keep the in-memory ring buffer small, this contains:
99
+ * - All selected cards (the actual session output).
100
+ * - The top-N highest-scoring non-selected cards (see DISCARDED_KEEP_TOP),
101
+ * useful for understanding "near misses" and filter behavior.
102
+ *
103
+ * The remaining low-score tail of the candidate pool (mostly ELO-window
104
+ * pull remnants that filters scored down) is summarized in `discardedTail`
105
+ * rather than retained verbatim — each retained card carries a multi-KB
106
+ * provenance trail, and the tail is typically hundreds of cards per run.
107
+ */
96
108
  cards: Array<{
97
109
  cardId: string;
98
110
  courseId: string;
@@ -105,6 +117,26 @@ export interface PipelineRunReport {
105
117
  tags?: string[];
106
118
  selected: boolean;
107
119
  }>;
120
+
121
+ /**
122
+ * Summary of the discarded tail of the candidate pool — cards that were
123
+ * generated and scored but neither selected nor retained in `cards`.
124
+ *
125
+ * Provides breadcrumbs for "where did card X go?" investigations without
126
+ * the memory cost of keeping every dropped candidate's provenance.
127
+ *
128
+ * Future: may carry a bloom filter of discarded cardIds so callers can
129
+ * ask "was X in this run's discard tail?" with bounded memory cost.
130
+ */
131
+ discardedTail?: {
132
+ count: number;
133
+ /** [min, max] finalScore across the discarded tail. */
134
+ scoreRange?: [number, number];
135
+ /** [min, max] cardElo across the discarded tail (where parseable). */
136
+ eloRange?: [number, number];
137
+ /** Human-readable note for console display. */
138
+ note: string;
139
+ };
108
140
  }
109
141
 
110
142
  /**
@@ -113,6 +145,30 @@ export interface PipelineRunReport {
113
145
  const MAX_RUNS = 10;
114
146
  const runHistory: PipelineRunReport[] = [];
115
147
 
148
+ /**
149
+ * Cap on non-selected ("discarded") cards retained per run report.
150
+ *
151
+ * Pipeline candidate pools are typically hundreds of cards (ELO window pull
152
+ * + generators), but most are scored down by filters and never selected.
153
+ * Their provenance trails are the dominant text-memory cost in the ring
154
+ * buffer (each entry ~1–2 KB). Retaining only the top-N "near misses"
155
+ * preserves debugging value while collapsing memory roughly an order of
156
+ * magnitude or more.
157
+ */
158
+ const DISCARDED_KEEP_TOP = 25;
159
+
160
+ /**
161
+ * Programmatic clear of pipeline run history.
162
+ *
163
+ * Called by session-lifecycle hooks (session end, course switch, logout)
164
+ * to release retained provenance/tag arrays once they're no longer useful
165
+ * for in-session debugging. The interactive `clear()` debug method remains
166
+ * for manual use.
167
+ */
168
+ export function clearRunHistory(): void {
169
+ runHistory.length = 0;
170
+ }
171
+
116
172
  /**
117
173
  * Determine card origin from provenance trail.
118
174
  */
@@ -171,7 +227,11 @@ export function buildRunReport(
171
227
  ): Omit<PipelineRunReport, 'runId' | 'timestamp'> {
172
228
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
173
229
 
174
- const cards = allCards.map((card) => ({
230
+ // `allCards` arrives post-filter and sorted by score (desc). Partition
231
+ // into selected vs not-selected, then retain only the top-N of the
232
+ // non-selected group to bound memory. The remaining low-score tail is
233
+ // summarized rather than kept (see discardedTail).
234
+ const toReport = (card: WeightedCard) => ({
175
235
  cardId: card.cardId,
176
236
  courseId: card.courseId,
177
237
  origin: getOrigin(card),
@@ -181,7 +241,55 @@ export function buildRunReport(
181
241
  provenance: card.provenance,
182
242
  tags: card.tags,
183
243
  selected: selectedIds.has(card.cardId),
184
- }));
244
+ });
245
+
246
+ const selectedReported: ReturnType<typeof toReport>[] = [];
247
+ const nearMissReported: ReturnType<typeof toReport>[] = [];
248
+ const discardedTailCards: WeightedCard[] = [];
249
+
250
+ let nonSelectedSeen = 0;
251
+ for (const card of allCards) {
252
+ if (selectedIds.has(card.cardId)) {
253
+ selectedReported.push(toReport(card));
254
+ } else if (nonSelectedSeen < DISCARDED_KEEP_TOP) {
255
+ nearMissReported.push(toReport(card));
256
+ nonSelectedSeen++;
257
+ } else {
258
+ discardedTailCards.push(card);
259
+ }
260
+ }
261
+
262
+ const cards = [...selectedReported, ...nearMissReported];
263
+
264
+ let discardedTail: PipelineRunReport['discardedTail'];
265
+ if (discardedTailCards.length > 0) {
266
+ let scoreMin = Infinity;
267
+ let scoreMax = -Infinity;
268
+ let eloMin = Infinity;
269
+ let eloMax = -Infinity;
270
+ let eloSeen = false;
271
+ for (const c of discardedTailCards) {
272
+ if (c.score < scoreMin) scoreMin = c.score;
273
+ if (c.score > scoreMax) scoreMax = c.score;
274
+ const elo = parseCardElo(c.provenance);
275
+ if (elo !== undefined) {
276
+ eloSeen = true;
277
+ if (elo < eloMin) eloMin = elo;
278
+ if (elo > eloMax) eloMax = elo;
279
+ }
280
+ }
281
+ const eloFragment = eloSeen ? `, ELO ${eloMin}–${eloMax}` : '';
282
+ discardedTail = {
283
+ count: discardedTailCards.length,
284
+ scoreRange: [scoreMin, scoreMax],
285
+ eloRange: eloSeen ? [eloMin, eloMax] : undefined,
286
+ note:
287
+ `${discardedTailCards.length} additional candidate(s) scored below the ` +
288
+ `top ${DISCARDED_KEEP_TOP} near-misses and were not retained ` +
289
+ `(score ${scoreMin.toExponential(2)}–${scoreMax.toExponential(2)}${eloFragment}). ` +
290
+ `Likely ELO-window pull remnants filtered out by hierarchy/lesson/priority gates.`,
291
+ };
292
+ }
185
293
 
186
294
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === 'review').length;
187
295
  const newSelected = selectedCards.filter((c) => getOrigin(c) === 'new').length;
@@ -199,6 +307,7 @@ export function buildRunReport(
199
307
  reviewsSelected,
200
308
  newSelected,
201
309
  cards,
310
+ discardedTail,
202
311
  };
203
312
  }
204
313
 
@@ -273,6 +382,50 @@ let _uiContainer: HTMLElement | null = null;
273
382
  let _selectedRunIndex: number | null = null;
274
383
  let _cardSearchQuery = '';
275
384
 
385
+ function escapeHtml(s: string): string {
386
+ return s
387
+ .replace(/&/g, '&amp;')
388
+ .replace(/</g, '&lt;')
389
+ .replace(/>/g, '&gt;');
390
+ }
391
+
392
+ function escapeAttr(s: string): string {
393
+ return escapeHtml(s).replace(/"/g, '&quot;');
394
+ }
395
+
396
+ function copyTextToClipboard(text: string, btn?: HTMLElement): void {
397
+ const done = () => {
398
+ if (!btn) return;
399
+ const orig = btn.textContent ?? 'Copy';
400
+ btn.textContent = 'Copied!';
401
+ btn.classList.add('copied');
402
+ setTimeout(() => {
403
+ btn.textContent = orig;
404
+ btn.classList.remove('copied');
405
+ }, 1200);
406
+ };
407
+ const fallback = () => {
408
+ const ta = document.createElement('textarea');
409
+ ta.value = text;
410
+ ta.style.position = 'fixed';
411
+ ta.style.opacity = '0';
412
+ document.body.appendChild(ta);
413
+ ta.select();
414
+ try {
415
+ document.execCommand('copy');
416
+ } catch (e) {
417
+ logger.warn(`[Pipeline Debug] Copy failed: ${e}`);
418
+ }
419
+ document.body.removeChild(ta);
420
+ done();
421
+ };
422
+ if (navigator.clipboard?.writeText) {
423
+ navigator.clipboard.writeText(text).then(done).catch(fallback);
424
+ } else {
425
+ fallback();
426
+ }
427
+ }
428
+
276
429
  function renderUI(): void {
277
430
  if (!_uiContainer) return;
278
431
 
@@ -333,6 +486,13 @@ function renderUI(): void {
333
486
  #sk-pipeline-debugger .close-btn { background: #dc3545; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
334
487
  #sk-pipeline-debugger .search-box { margin-bottom: 1rem; width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; }
335
488
  #sk-pipeline-debugger .provenance { font-size: 12px; color: #666; margin-top: 0.25rem; white-space: pre-wrap; font-family: monospace; background: #f8f9fa; padding: 0.5rem; border-radius: 4px; }
489
+ #sk-pipeline-debugger .run-label { display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.4rem; background: #fff3cd; color: #664d03; border-radius: 3px; font-family: monospace; font-size: 11px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
490
+ #sk-pipeline-debugger .label-banner { display: inline-block; padding: 0.25rem 0.6rem; background: #fff3cd; color: #664d03; border-radius: 4px; font-family: monospace; font-size: 13px; margin: 0 0 0.75rem 0; }
491
+ #sk-pipeline-debugger .copy-btn { background: #0d6efd; color: white; border: none; padding: 0.25rem 0.6rem; border-radius: 3px; cursor: pointer; font-size: 12px; margin-left: 0.5rem; }
492
+ #sk-pipeline-debugger .copy-btn:hover { background: #0b5ed7; }
493
+ #sk-pipeline-debugger .copy-btn.copied { background: #198754; }
494
+ #sk-pipeline-debugger .section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; }
495
+ #sk-pipeline-debugger .section-head h3 { margin: 0; }
336
496
  `;
337
497
 
338
498
  const runListHtml =
@@ -345,6 +505,7 @@ function renderUI(): void {
345
505
  <strong>${r.timestamp.toLocaleTimeString()}</strong><br/>
346
506
  <small>${r.courseName || r.courseId.slice(0, 8)}</small><br/>
347
507
  <small>${r.finalCount} cards selected</small>
508
+ ${r.hints?._label ? `<br/><span class="run-label" title="${escapeAttr(r.hints._label)}">${escapeHtml(r.hints._label)}</span>` : ''}
348
509
  </div>
349
510
  `
350
511
  )
@@ -359,11 +520,13 @@ function renderUI(): void {
359
520
  !_cardSearchQuery || c.cardId.toLowerCase().includes(_cardSearchQuery.toLowerCase())
360
521
  );
361
522
 
523
+ const labelText = selectedRun.hints?._label ?? '(no label)';
362
524
  detailsHtml = `
363
525
  <h2>Run: ${selectedRun.runId}</h2>
526
+ <div class="label-banner" title="${escapeAttr(labelText)}">${escapeHtml(labelText)}</div>
364
527
  <p>
365
- <strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
366
- <strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
528
+ <strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
529
+ <strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
367
530
  <strong>User ELO:</strong> ${selectedRun.userElo ?? 'unknown'}
368
531
  </p>
369
532
 
@@ -382,7 +545,10 @@ function renderUI(): void {
382
545
  ${
383
546
  selectedRun.hints
384
547
  ? `
385
- <h3>Ephemeral Hints</h3>
548
+ <div class="section-head">
549
+ <h3>Ephemeral Hints</h3>
550
+ <button class="copy-btn" onclick="window.skuilder.pipeline._copyConfig('${selectedRun.runId}', this)">Copy config</button>
551
+ </div>
386
552
  <table>
387
553
  ${selectedRun.hints._label ? `<tr><th>Label</th><td>${selectedRun.hints._label}</td></tr>` : ''}
388
554
  ${
@@ -434,7 +600,10 @@ function renderUI(): void {
434
600
  </tbody>
435
601
  </table>
436
602
 
437
- <h3>Cards (${selectedRun.finalCount} selected / ${selectedRun.cards.length} total)</h3>
603
+ <div class="section-head">
604
+ <h3>Cards (${selectedRun.finalCount} selected / ${selectedRun.cards.length} total)</h3>
605
+ <button class="copy-btn" onclick="window.skuilder.pipeline._copyResults('${selectedRun.runId}', this)">Copy results</button>
606
+ </div>
438
607
  <input type="text" class="search-box" placeholder="Search Card ID..." value="${_cardSearchQuery}" oninput="window.skuilder.pipeline._setSearch(this.value)">
439
608
 
440
609
  <table>
@@ -554,7 +723,19 @@ export const pipelineDebugAPI = {
554
723
  return;
555
724
  }
556
725
  }
557
- logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
726
+ // Not found in any retained card list. If runs have discarded tails, the
727
+ // card may have been in the low-score remnant pool — surface that as a
728
+ // breadcrumb rather than a flat "not found".
729
+ const runsWithTails = runHistory.filter((r) => r.discardedTail && r.discardedTail.count > 0);
730
+ if (runsWithTails.length > 0) {
731
+ logger.info(
732
+ `[Pipeline Debug] Card '${cardId}' not found in retained cards. ` +
733
+ `${runsWithTails.length} run(s) have discarded tails that were not retained — ` +
734
+ `the card may have been a low-score candidate. See run.discardedTail for ranges.`
735
+ );
736
+ } else {
737
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
738
+ }
558
739
  },
559
740
 
560
741
  /**
@@ -932,6 +1113,55 @@ export const pipelineDebugAPI = {
932
1113
  renderUI();
933
1114
  },
934
1115
 
1116
+ /**
1117
+ * Internal UI helpers
1118
+ * @internal
1119
+ */
1120
+ _copyConfig(runId: string, btn?: HTMLElement): void {
1121
+ const run = runHistory.find((r) => r.runId === runId);
1122
+ if (!run) return;
1123
+ const payload = {
1124
+ runId: run.runId,
1125
+ timestamp: run.timestamp.toISOString(),
1126
+ courseId: run.courseId,
1127
+ courseName: run.courseName,
1128
+ hints: run.hints ?? null,
1129
+ };
1130
+ copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
1131
+ },
1132
+
1133
+ /**
1134
+ * Internal UI helpers
1135
+ * @internal
1136
+ *
1137
+ * Copies an "abridged" view of results: just the selected cards with their
1138
+ * generator, origin, final score, and the top provenance reason. Designed
1139
+ * for pasting into bug reports without flooding with full provenance.
1140
+ */
1141
+ _copyResults(runId: string, btn?: HTMLElement): void {
1142
+ const run = runHistory.find((r) => r.runId === runId);
1143
+ if (!run) return;
1144
+ const selected = run.cards
1145
+ .filter((c) => c.selected)
1146
+ .sort((a, b) => b.finalScore - a.finalScore)
1147
+ .map((c) => ({
1148
+ cardId: c.cardId,
1149
+ generator: c.generator,
1150
+ origin: c.origin,
1151
+ score: Number(c.finalScore.toFixed(3)),
1152
+ topReason: c.provenance[0]?.reason ?? '',
1153
+ }));
1154
+ const payload = {
1155
+ runId: run.runId,
1156
+ label: run.hints?._label ?? null,
1157
+ finalCount: run.finalCount,
1158
+ newSelected: run.newSelected,
1159
+ reviewsSelected: run.reviewsSelected,
1160
+ selected,
1161
+ };
1162
+ copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
1163
+ },
1164
+
935
1165
  /**
936
1166
  * Show help.
937
1167
  */
@@ -343,8 +343,19 @@ export class SessionController<TView = unknown> extends Loggable {
343
343
  /**
344
344
  * Request a mid-session replan. Re-runs the pipeline with current user state
345
345
  * and atomically replaces the newQ contents. Safe to call at any time during
346
- * a session — if called while a replan is already in progress, returns the
347
- * existing replan promise (no duplicate work).
346
+ * a session.
347
+ *
348
+ * Concurrency policy:
349
+ * - Two unhinted auto-replans never run in parallel; the second coalesces
350
+ * into the first (returns the same promise).
351
+ * - A hint-bearing replan that arrives while another replan is in flight
352
+ * is queued to run **after** the in-flight one rather than dropped.
353
+ * This preserves caller intent (label, requireCards, excludeTags,
354
+ * limit, minFollowUpCards) instead of silently discarding it. Without
355
+ * queueing, a background auto-replan that started just before a
356
+ * completion-triggered replan would clobber the queue with unhinted
357
+ * results (e.g. surfacing another gpc-intro card right after one
358
+ * completed, skipping the prescribed `c-wst-*` follow-up).
348
359
  *
349
360
  * Does NOT affect reviewQ or failedQ.
350
361
  *
@@ -365,14 +376,83 @@ export class SessionController<TView = unknown> extends Loggable {
365
376
  this._depletionReplanAttempted = false;
366
377
  }
367
378
 
379
+ const hasIntent = this._replanHasIntent(opts);
380
+
368
381
  if (this._replanPromise) {
369
- this.log('Replan already in progress, awaiting existing replan');
370
- return this._replanPromise;
382
+ if (!hasIntent) {
383
+ this.log('Replan already in progress, coalescing unhinted auto-replan');
384
+ return this._replanPromise;
385
+ }
386
+
387
+ // Queue the hint-bearing replan behind the in-flight one rather than
388
+ // dropping its hints. See class comment above for rationale.
389
+ const labelTag = opts.label ? ` [${opts.label}]` : '';
390
+ this.log(
391
+ `Replan in progress; queueing hint-bearing replan${labelTag} behind in-flight run`
392
+ );
393
+ const inflight = this._replanPromise;
394
+ const queued: Promise<void> = inflight
395
+ // Swallow errors in the upstream replan so the downstream still runs
396
+ // (the user's intent is independent of whether the prior run succeeded).
397
+ .catch(() => undefined)
398
+ .then(() => this._runReplan(opts));
399
+
400
+ this._replanPromise = queued.finally(() => {
401
+ if (this._replanPromise === queued) this._replanPromise = null;
402
+ });
403
+
404
+ return queued;
371
405
  }
372
406
 
407
+ const run = this._runReplan(opts);
408
+ this._replanPromise = run.finally(() => {
409
+ if (this._replanPromise === run) this._replanPromise = null;
410
+ });
411
+
412
+ await run;
413
+ }
414
+
415
+ /**
416
+ * True when a requestReplan call carries caller intent that must not be
417
+ * silently dropped. Bare unhinted auto-replans (depletion / quality
418
+ * triggers in nextCard) return false and may coalesce.
419
+ */
420
+ private _replanHasIntent(opts: ReplanOptions): boolean {
421
+ if (opts.label) return true;
422
+ if (opts.limit !== undefined) return true;
423
+ if (opts.minFollowUpCards !== undefined) return true;
424
+ if (opts.mode && opts.mode !== 'replace') return true;
425
+ if (opts.hints && Object.keys(opts.hints).length > 0) return true;
426
+ return false;
427
+ }
428
+
429
+ /**
430
+ * Body of a single replan: populate auto-excludes, stash hints on
431
+ * sources, log, then run the pipeline. Extracted so it can be invoked
432
+ * either immediately (no in-flight replan) or queued (chained after
433
+ * the in-flight one resolves).
434
+ *
435
+ * IMPORTANT: hint stash and the queue-state snapshot used to build
436
+ * excludeCards happen at *invocation* time, not at *queue* time. For a
437
+ * queued replan that means excludes reflect the state after the prior
438
+ * replan landed — which is what we want, since the prior replan's
439
+ * newQ.peek(0) is the imminent draw we need to exclude.
440
+ */
441
+ private async _runReplan(opts: ReplanOptions): Promise<void> {
373
442
  // Exclude all cards already presented this session. The pipeline may
374
443
  // not yet see their encounter records (async writes), so without this
375
444
  // they can re-enter newQ via replaceAll and cause duplicates.
445
+ //
446
+ // Also exclude newQ.peek(0): the imminent draw. When a replan fires
447
+ // from inside nextCard() (auto depletion/quality trigger) or as a
448
+ // deferred post-submit replan, the next-up card is about to become
449
+ // _currentCard but isn't yet, and hasn't yet landed in _sessionRecord.
450
+ // Without this, the just-drawn card can be re-seated at the head of
451
+ // the replaced newQ and shown twice in a row — most visible in early
452
+ // sessions where state is sparse and triggers fire aggressively.
453
+ // Only the head is excluded; deeper newQ entries are still fair game
454
+ // for the new plan (they aren't at risk of double-display since the
455
+ // old queue is replaced atomically and only its head gets drawn).
376
456
  if (!opts.hints) opts.hints = {};
377
457
  const hints = opts.hints;
378
458
  const excludeSet = new Set(hints.excludeCards ?? []);
@@ -383,6 +463,9 @@ export class SessionController<TView = unknown> extends Loggable {
383
463
  for (const rec of this._sessionRecord) {
384
464
  excludeSet.add(rec.card.card_id);
385
465
  }
466
+ if (this.newQ.length > 0) {
467
+ excludeSet.add(this.newQ.peek(0).cardID);
468
+ }
386
469
 
387
470
  hints.excludeCards = [...excludeSet];
388
471
 
@@ -409,13 +492,7 @@ export class SessionController<TView = unknown> extends Loggable {
409
492
  this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
410
493
  }
411
494
 
412
- this._replanPromise = this._executeReplan(opts);
413
-
414
- try {
415
- await this._replanPromise;
416
- } finally {
417
- this._replanPromise = null;
418
- }
495
+ await this._executeReplan(opts);
419
496
  }
420
497
 
421
498
  /**
@@ -1,4 +1,5 @@
1
1
  import { logger } from '../util/logger';
2
+ import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
2
3
 
3
4
  // ============================================================================
4
5
  // SESSION DEBUGGER
@@ -75,6 +76,13 @@ export function startSessionTracking(
75
76
  newQLength: number,
76
77
  failedQLength: number
77
78
  ): void {
79
+ // Release pipeline run-history memory before this session begins piling
80
+ // new runs on top of the previous session's retained reports. Clearing on
81
+ // session START (rather than END) keeps post-session inspection — the
82
+ // dominant debugging workflow — fully functional: a finished session's
83
+ // run history sits intact until the user actually begins another one.
84
+ clearPipelineRunHistory();
85
+
78
86
  const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
79
87
 
80
88
  activeSession = {