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.
- package/dist/assets/{DataModel-D93FoaOm.js → DataModel-DCEgMTjH.js} +1 -1
- package/dist/assets/{History-CXxxqNP1.js → History-Bfdoywlh.js} +1 -1
- package/dist/assets/{Hooks-CzeYFQjq.js → Hooks-CKDJjTJV.js} +1 -1
- package/dist/assets/{HostBilko-nESrTRIg.js → HostBilko-CDVHE0hT.js} +1 -1
- package/dist/assets/{Library-D2zPkwOC.js → Library-7akfgy67.js} +1 -1
- package/dist/assets/{MarkdownEditor-DbinP78A.js → MarkdownEditor-DDesiqet.js} +1 -1
- package/dist/assets/{McpServers-Bu_sVGLr.js → McpServers-D4lZtXof.js} +1 -1
- package/dist/assets/{Memory-BgiI6LZ_.js → Memory-CeI8G1w5.js} +1 -1
- package/dist/assets/{Permissions-C1MMzZRm.js → Permissions-BGleLja4.js} +1 -1
- package/dist/assets/{Plugins-Dhu6wAZi.js → Plugins-P4eCXy_S.js} +2 -2
- package/dist/assets/{ProvenanceBadge-C3AcWKMG.js → ProvenanceBadge-BFczzI1B.js} +1 -1
- package/dist/assets/{SaveBar-plwYm0iO.js → SaveBar-l9jnEMES.js} +1 -1
- package/dist/assets/{Scheduler-BVYTeh39.js → Scheduler-C_phpeUS.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DFy0vv_o.js → ScopeSwitcher-CjJi8ST5.js} +1 -1
- package/dist/assets/{Settings-DIhGgdfs.js → Settings-BIwZ3FGQ.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DsI7hRPB.js → SkillReferenceGraph-CjbO01Sg.js} +1 -1
- package/dist/assets/{Skills-3E748UfB.js → Skills-BxZ9_fCR.js} +1 -1
- package/dist/assets/{SystemPrompt-BqmfUJOd.js → SystemPrompt-D8vfFb8b.js} +1 -1
- package/dist/assets/{TagLibrary-CPz6fO8Q.js → TagLibrary-2vzUo7YL.js} +1 -1
- package/dist/assets/{TiptapBody-D9EoBQ1P.js → TiptapBody-B8ZfbK90.js} +1 -1
- package/dist/assets/{Toggle-BHAmZNDn.js → Toggle-BbW4nGGf.js} +1 -1
- package/dist/assets/{index-tn5JIVPj.js → index-Cd98Q1lD.js} +3 -3
- package/dist/assets/{settingsSchema-Crqy_aNz.js → settingsSchema-IhUfucCF.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +2 -0
- package/src/main/__tests__/prdCreateAdoption.test.cjs +205 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +63 -6
- package/src/main/__tests__/scheduler-mutate-reentrancy.test.cjs +117 -0
- package/src/main/build-info.json +4 -4
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +66 -0
- package/src/main/lib/prdCreate.cjs +32 -7
- package/src/main/lib/watchdogHelpers.cjs +21 -5
- package/src/main/queueOps.cjs +17 -6
- 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`
|
|
106
|
-
*
|
|
107
|
-
* counts as older)
|
|
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,
|
package/src/main/queueOps.cjs
CHANGED
|
@@ -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
|
|
345
|
-
*
|
|
346
|
-
*
|
|
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({
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -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.
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
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
|
|
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,
|