claude-code-session-manager 0.97.0 → 0.98.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.
Files changed (35) hide show
  1. package/dist/assets/{DataModel-D93FoaOm.js → DataModel-DCEgMTjH.js} +1 -1
  2. package/dist/assets/{History-CXxxqNP1.js → History-Bfdoywlh.js} +1 -1
  3. package/dist/assets/{Hooks-CzeYFQjq.js → Hooks-CKDJjTJV.js} +1 -1
  4. package/dist/assets/{HostBilko-nESrTRIg.js → HostBilko-CDVHE0hT.js} +1 -1
  5. package/dist/assets/{Library-D2zPkwOC.js → Library-7akfgy67.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-DbinP78A.js → MarkdownEditor-DDesiqet.js} +1 -1
  7. package/dist/assets/{McpServers-Bu_sVGLr.js → McpServers-D4lZtXof.js} +1 -1
  8. package/dist/assets/{Memory-BgiI6LZ_.js → Memory-CeI8G1w5.js} +1 -1
  9. package/dist/assets/{Permissions-C1MMzZRm.js → Permissions-BGleLja4.js} +1 -1
  10. package/dist/assets/{Plugins-Dhu6wAZi.js → Plugins-P4eCXy_S.js} +2 -2
  11. package/dist/assets/{ProvenanceBadge-C3AcWKMG.js → ProvenanceBadge-BFczzI1B.js} +1 -1
  12. package/dist/assets/{SaveBar-plwYm0iO.js → SaveBar-l9jnEMES.js} +1 -1
  13. package/dist/assets/{Scheduler-BVYTeh39.js → Scheduler-C_phpeUS.js} +1 -1
  14. package/dist/assets/{ScopeSwitcher-DFy0vv_o.js → ScopeSwitcher-CjJi8ST5.js} +1 -1
  15. package/dist/assets/{Settings-DIhGgdfs.js → Settings-BIwZ3FGQ.js} +1 -1
  16. package/dist/assets/{SkillReferenceGraph-DsI7hRPB.js → SkillReferenceGraph-CjbO01Sg.js} +1 -1
  17. package/dist/assets/{Skills-3E748UfB.js → Skills-BxZ9_fCR.js} +1 -1
  18. package/dist/assets/{SystemPrompt-BqmfUJOd.js → SystemPrompt-D8vfFb8b.js} +1 -1
  19. package/dist/assets/{TagLibrary-CPz6fO8Q.js → TagLibrary-2vzUo7YL.js} +1 -1
  20. package/dist/assets/{TiptapBody-D9EoBQ1P.js → TiptapBody-B8ZfbK90.js} +1 -1
  21. package/dist/assets/{Toggle-BHAmZNDn.js → Toggle-BbW4nGGf.js} +1 -1
  22. package/dist/assets/{index-tn5JIVPj.js → index-Cd98Q1lD.js} +3 -3
  23. package/dist/assets/{settingsSchema-Crqy_aNz.js → settingsSchema-IhUfucCF.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +2 -1
  26. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +2 -0
  27. package/src/main/__tests__/prdCreateAdoption.test.cjs +205 -0
  28. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +63 -6
  29. package/src/main/__tests__/scheduler-mutate-reentrancy.test.cjs +117 -0
  30. package/src/main/build-info.json +4 -4
  31. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +66 -0
  32. package/src/main/lib/prdCreate.cjs +32 -7
  33. package/src/main/lib/watchdogHelpers.cjs +21 -5
  34. package/src/main/queueOps.cjs +17 -6
  35. package/src/main/scheduler.cjs +65 -5
@@ -96,19 +96,28 @@ function heartbeatFresh(heartbeatPath = schedulerPaths.heartbeatPath(), maxAgeMs
96
96
  }
97
97
 
98
98
  const DEFAULT_DISPATCH_DEAD_MS = 30 * 60_000;
99
+ const DEFAULT_TICK_WEDGED_DEAD_MS = 10 * 60_000;
99
100
 
100
101
  /**
101
- * evaluateDispatchLiveness(entry, now, { deadMs }?) → { dead, reason }
102
+ * evaluateDispatchLiveness(entry, now, { deadMs, wedgedDeadMs }?) → { dead, reason }
102
103
  *
103
104
  * Pure. Observability only — a logically-dead scheduler (wedged tick chain,
104
105
  * leaked slots) still writes fresh heartbeats, so freshness alone can't see
105
- * it. `dead` only when ALL hold: heartbeat fresh (not degraded, within
106
- * DEFAULT_MAX_AGE_MS), dispatch.lastRunAt older than deadMs (absent/unparseable
107
- * counts as older), pendingDispatchable > 0, runningCount === 0, not paused.
106
+ * it. `dead` when EITHER:
107
+ * - dispatch.lastTickReason === 'wedged' and lastDispatchAttemptAt is
108
+ * older than wedgedDeadMs (absent/unparseable counts as older) — a
109
+ * wedged reconcile never derives rows, so pendingDispatchable stays 0
110
+ * and the rule below can never fire (2026-09-25: watchdog logged
111
+ * 'alive'/'nothing-dispatchable' every 3 min through a 30+ min mutate
112
+ * deadlock because of exactly this gap); or
113
+ * - ALL of: dispatch.lastRunAt older than deadMs (absent/unparseable
114
+ * counts as older), pendingDispatchable > 0, runningCount === 0.
115
+ * Both rules require: heartbeat fresh (not degraded, within
116
+ * DEFAULT_MAX_AGE_MS) and not paused/draining.
108
117
  * Callers must NOT relaunch on this: the process is alive, and a relaunch
109
118
  * would spawn a second app on top of it.
110
119
  */
111
- function evaluateDispatchLiveness(entry, now = Date.now(), { deadMs = DEFAULT_DISPATCH_DEAD_MS } = {}) {
120
+ function evaluateDispatchLiveness(entry, now = Date.now(), { deadMs = DEFAULT_DISPATCH_DEAD_MS, wedgedDeadMs = DEFAULT_TICK_WEDGED_DEAD_MS } = {}) {
112
121
  if (!entry || entry.degraded === true || typeof entry.ts !== 'number') {
113
122
  return { dead: false, reason: 'no-fresh-heartbeat' };
114
123
  }
@@ -117,6 +126,12 @@ function evaluateDispatchLiveness(entry, now = Date.now(), { deadMs = DEFAULT_DI
117
126
  if (!d || typeof d !== 'object') return { dead: false, reason: 'no-dispatch-field' };
118
127
  if (d.paused) return { dead: false, reason: 'paused' };
119
128
  if (d.drain) return { dead: false, reason: 'draining' };
129
+ if (d.lastTickReason === 'wedged') {
130
+ const lastAttemptMs = Date.parse(d.lastDispatchAttemptAt ?? '');
131
+ if (!Number.isFinite(lastAttemptMs) || now - lastAttemptMs > wedgedDeadMs) {
132
+ return { dead: true, reason: 'tick-wedged' };
133
+ }
134
+ }
120
135
  if (!(d.pendingDispatchable > 0)) return { dead: false, reason: 'nothing-dispatchable' };
121
136
  if (d.runningCount !== 0) return { dead: false, reason: 'jobs-running' };
122
137
  const lastRunMs = Date.parse(d.lastRunAt ?? '');
@@ -461,6 +476,7 @@ module.exports = {
461
476
  maybeRelaunchApp,
462
477
  DEFAULT_MAX_AGE_MS,
463
478
  DEFAULT_DISPATCH_DEAD_MS,
479
+ DEFAULT_TICK_WEDGED_DEAD_MS,
464
480
  DEFAULT_LOCK_STALE_MS,
465
481
  DEFAULT_FINALIZE_BUDGET_MS,
466
482
  DEFAULT_RELAUNCH_DEBOUNCE_MS,
@@ -341,13 +341,17 @@ async function archiveOne(slug, ts, cwd) {
341
341
  * Injected by index.cjs at registration time (see registerQueueOpsHandlers)
342
342
  * so a manual archive can retire any still-runnable queue job for the same
343
343
  * slug without queueOps.cjs importing scheduler.cjs (circular — scheduler.cjs
344
- * already requires queueOps.cjs). No-op until set; the auto-archive path
345
- * never needs it (selectAutoArchivable only ever selects already-completed
346
- * jobs).
344
+ * already requires queueOps.cjs). No-op until set. Only ever invoked when
345
+ * archiveMany is called with { retire: true } (the default, used by the
346
+ * manual schedule:archive-prd path) — autoArchiveCompleted passes
347
+ * { retire: false } because it calls retireCompletedSlugsFn (== scheduler's
348
+ * retireCompletedSlugs) from inside reconcile(), itself always inside a
349
+ * mutate() body; retireCompletedSlugs's own mutate() call would then queue
350
+ * behind the mutate awaiting it and deadlock every tick (2026-09-25 incident).
347
351
  */
348
352
  let retireCompletedSlugsFn = async () => {};
349
353
 
350
- async function archiveMany(slugs, cwd) {
354
+ async function archiveMany(slugs, cwd, { retire = true } = {}) {
351
355
  if (!Array.isArray(slugs) || slugs.length === 0) {
352
356
  return { ok: true, archived: 0, archivedTo: null, results: [] };
353
357
  }
@@ -362,7 +366,7 @@ async function archiveMany(slugs, cwd) {
362
366
  }
363
367
  const archived = results.filter((r) => r.ok).length;
364
368
  const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
365
- if (archivedSlugs.length > 0) {
369
+ if (retire && archivedSlugs.length > 0) {
366
370
  await retireCompletedSlugsFn(archivedSlugs).catch((e) => {
367
371
  logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
368
372
  });
@@ -440,6 +444,13 @@ function selectAutoArchivable(jobs, { nowMs, retentionMs } = {}) {
440
444
  * the same path-containment. No-op (without touching disk) when
441
445
  * SM_PRD_AUTOARCHIVE_DISABLE=1. Each successful move is logged one line
442
446
  * (slug -> dest).
447
+ *
448
+ * Passes { retire: false } to archiveMany: this runs inside reconcile(),
449
+ * which always runs inside a mutate() body or tickBody, so invoking
450
+ * retireCompletedSlugs (which itself calls mutate()) here would queue that
451
+ * mutate behind the one already awaiting it and deadlock every tick — proven
452
+ * live 2026-09-25. Also unneeded on the merits: selectAutoArchivable only
453
+ * ever selects already-completed jobs, so there is nothing left to retire.
443
454
  */
444
455
  async function autoArchiveCompleted(state, { nowMs } = {}) {
445
456
  if (process.env.SM_PRD_AUTOARCHIVE_DISABLE === '1') {
@@ -450,7 +461,7 @@ async function autoArchiveCompleted(state, { nowMs } = {}) {
450
461
  if (slugs.length === 0) {
451
462
  return { ok: true, archived: 0, archivedTo: null, results: [] };
452
463
  }
453
- const result = await archiveMany(slugs);
464
+ const result = await archiveMany(slugs, undefined, { retire: false });
454
465
  for (const r of result.results) {
455
466
  if (r.ok) {
456
467
  logs.writeLine({
@@ -47,6 +47,7 @@ const fs = require('node:fs');
47
47
  const fsp = require('node:fs/promises');
48
48
  const path = require('node:path');
49
49
  const os = require('node:os');
50
+ const { AsyncLocalStorage } = require('node:async_hooks');
50
51
  const { startDispatchLoop } = require('./lib/dispatchLoop.cjs');
51
52
  const schedulerPaths = require('./lib/schedulerPaths.cjs');
52
53
  const { randomUUID } = require('node:crypto');
@@ -1328,9 +1329,17 @@ async function archiveCompletedPrd(slug, cwd) {
1328
1329
  * `schedule:archive-prd`) so a stale queue entry can never survive to fire
1329
1330
  * against a PRD that no longer exists in the live prds/ dir — the same
1330
1331
  * ENOENT-avoidance archivedTwinExists provides in executeJob, applied at the
1331
- * archiving source instead of at fire-time. auto-archived slugs never need
1332
- * this (selectAutoArchivable in queueOps.cjs only selects already-completed
1333
- * jobs), so this is exercised only by the manual archive path.
1332
+ * archiving source instead of at fire-time.
1333
+ *
1334
+ * MUST NEVER be called from inside a mutate() body (directly, or transitively
1335
+ * via reconcile()/tickBody, which always run inside one) — this function
1336
+ * itself calls mutate() below, and a mutate queued from inside a mutate body
1337
+ * queues behind the very mutate awaiting it, so mutateTail never settles:
1338
+ * every tick wedges machine-wide until the app restarts (proven live
1339
+ * 2026-09-25). That is exactly why queueOps.cjs's autoArchiveCompleted (which
1340
+ * DOES run inside reconcile()) calls archiveMany with { retire: false } and
1341
+ * skips this function entirely — it is exercised only by the manual archive
1342
+ * path (queueOps.cjs's `schedule:archive-prd`, called from outside mutate()).
1334
1343
  */
1335
1344
  async function retireCompletedSlugs(slugs) {
1336
1345
  const list = Array.isArray(slugs) ? slugs.filter(Boolean) : [];
@@ -2469,6 +2478,15 @@ async function writeQueue(state) {
2469
2478
  // preceding mutate threw, so the chain never deadlocks.
2470
2479
  let mutateTail = Promise.resolve();
2471
2480
 
2481
+ // Tracks whether the current async continuation is running inside a live
2482
+ // mutate() body. A mutate() called (and awaited) from inside another mutate's
2483
+ // `fn` would otherwise queue behind its own caller on mutateTail and deadlock
2484
+ // forever — this turns that class of bug into an immediate, audited rejection.
2485
+ // A callback (setTimeout/promise) scheduled from inside a mutate body but
2486
+ // firing AFTER that body has finished sees ctx.active === false and proceeds
2487
+ // normally, since AsyncLocalStorage propagates the store into it.
2488
+ const mutateCtx = new AsyncLocalStorage();
2489
+
2472
2490
  // Observe-only watchdog: a mutate body over MUTATE_WATCHDOG_MS is logged and
2473
2491
  // audited once per episode (latched until a mutate completes). mutateTail is
2474
2492
  // deliberately NEVER reset — it is what enforces the single-writer law, and
@@ -2477,6 +2495,12 @@ const MUTATE_WATCHDOG_MS = 60_000;
2477
2495
  let mutateWedgeLatched = false;
2478
2496
 
2479
2497
  function mutate(fn) {
2498
+ if (mutateCtx.getStore()?.active === true) {
2499
+ const err = new Error('mutate() re-entered from inside a running mutate body — would deadlock');
2500
+ console.error('[scheduler] MUTATE RE-ENTRANT', err.stack);
2501
+ appendAuditEvent('mutate_reentrant', { stack: String(err.stack).split('\n').slice(0, 8).join('\n') });
2502
+ return Promise.reject(err);
2503
+ }
2480
2504
  const next = mutateTail.then(async () => {
2481
2505
  const wedgeTimer = setTimeout(() => {
2482
2506
  if (mutateWedgeLatched) return;
@@ -2485,9 +2509,11 @@ function mutate(fn) {
2485
2509
  appendAuditEvent('mutate_wedged', { budgetMs: MUTATE_WATCHDOG_MS });
2486
2510
  }, MUTATE_WATCHDOG_MS);
2487
2511
  if (typeof wedgeTimer.unref === 'function') wedgeTimer.unref();
2512
+ const ctx = { active: true };
2488
2513
  try {
2489
- return await mutateBody(fn);
2514
+ return await mutateCtx.run(ctx, () => mutateBody(fn));
2490
2515
  } finally {
2516
+ ctx.active = false;
2491
2517
  clearTimeout(wedgeTimer);
2492
2518
  mutateWedgeLatched = false;
2493
2519
  }
@@ -3775,13 +3801,31 @@ function computeFireAt(state, nextResetIso) {
3775
3801
  return reset + (state.config.offsetMinutes * 60_000);
3776
3802
  }
3777
3803
 
3804
+ // PRD 1446: refreshNextReset() awaits billing.fetchUsage(), which can hang
3805
+ // or back off for minutes while the usage meter is rate-limited. Left
3806
+ // unbounded, that stalled THIS function's own mutate(reconcile) call for as
3807
+ // long as the fetch took — the 10-minute rescheduleTimer was meant to be a
3808
+ // second, independent path into reconcile() alongside the 60s pollLoop, but
3809
+ // a hung fetch silently took it out too, leaving newly-created PRDs
3810
+ // unadopted (PRDs 1420-1441, 2026-09-25). Racing against this timeout lets
3811
+ // the reconcile still run on the cached reset value; the abandoned
3812
+ // refreshNextReset() call is left to resolve on its own and update the
3813
+ // cache for next time.
3814
+ const RESCHEDULE_TIMER_BILLING_RACE_MS = 10_000;
3815
+
3778
3816
  async function rescheduleTimer() {
3779
3817
  clearFireTimer();
3780
3818
  // Wrap in try/catch — on failure use the cached value so the on-reset
3781
3819
  // timer can still be armed from the last known reset.
3782
3820
  let nextResetIso;
3783
3821
  try {
3784
- nextResetIso = await refreshNextReset();
3822
+ nextResetIso = await Promise.race([
3823
+ refreshNextReset(),
3824
+ new Promise((resolve) => {
3825
+ const t = setTimeout(() => resolve(cachedNextReset), RESCHEDULE_TIMER_BILLING_RACE_MS);
3826
+ if (typeof t.unref === 'function') t.unref();
3827
+ }),
3828
+ ]);
3785
3829
  } catch {
3786
3830
  nextResetIso = cachedNextReset;
3787
3831
  }
@@ -12453,6 +12497,19 @@ const remote = {
12453
12497
  }
12454
12498
  },
12455
12499
 
12500
+ // Triggers an immediate reconcile pass through the SAME seam resetJob
12501
+ // already uses below (broadcast's coalescer, whose getPayload runs
12502
+ // module.exports.reconcile) — no second reconcile implementation. PRD
12503
+ // 1446: prdCreate.cjs's createPrd() calls this right after a successful
12504
+ // write so a fresh PRD becomes a pending queue row without waiting for
12505
+ // the next scheduled pass — the 60s pollLoop only reaches reconcile() via
12506
+ // maybeLaunchWhenAvailable, which returns early while zero rows are
12507
+ // pending, and the 10-minute rescheduleTimer can itself stall behind a
12508
+ // slow/hung billing fetch (see rescheduleTimer's own bounded race below).
12509
+ async requestReconcile() {
12510
+ await broadcast({ flush: true });
12511
+ },
12512
+
12456
12513
  // User-initiated pause/resume — the admin-route/MCP twins of the
12457
12514
  // schedule:pause / schedule:resume IPC handlers, through the same setPaused /
12458
12515
  // clearPause. Pause stops NEW dispatch only; running jobs are never touched.
@@ -12876,8 +12933,11 @@ module.exports = {
12876
12933
  computeDegradedBudget,
12877
12934
  healRefusalReason,
12878
12935
  writeQueue,
12936
+ _mutateForTests: mutate, // mutate() has no other exported call site; test-only seam.
12879
12937
  reconcile,
12880
12938
  broadcast,
12939
+ rescheduleTimer,
12940
+ RESCHEDULE_TIMER_BILLING_RACE_MS,
12881
12941
  reconcileSourcePromptId,
12882
12942
  allocateParallelGroup,
12883
12943
  selectHistoryJobs,