claude-code-session-manager 0.39.2 → 0.39.4
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/{TiptapBody-MCzz_6Zm.js → TiptapBody-BSRtwtPP.js} +1 -1
- package/dist/assets/{index-DVlD8N1X.css → index-DyLm7GM_.css} +1 -1
- package/dist/assets/{index-BPbLVT6E.js → index-XGsBHqYu.js} +563 -563
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +19 -13
- package/scripts/lib/watchdogHelpers.cjs +57 -21
- package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
- package/src/main/__tests__/queueHistory.test.cjs +9 -4
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/health.cjs +46 -10
- package/src/main/index.cjs +6 -3
- package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
- package/src/main/lib/epicMint.cjs +144 -0
- package/src/main/lib/prdLocations.cjs +54 -1
- package/src/main/lib/prdMigration.cjs +47 -1
- package/src/main/lib/queueHistory.cjs +85 -37
- package/src/main/lib/queueStore.cjs +299 -0
- package/src/main/lib/sessionSlots.cjs +85 -0
- package/src/main/queueOps.cjs +20 -1
- package/src/main/scheduler.cjs +241 -49
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queueStore.cjs — federated scheduler state (2026-07-31 domain-model
|
|
3
|
+
* decision: "retire the global-aware scheduler").
|
|
4
|
+
*
|
|
5
|
+
* The old system of record was one global
|
|
6
|
+
* `~/.claude/session-manager/scheduled-plans/queue.json`. It is retired.
|
|
7
|
+
* State now lives where it belongs in the TAB → EPIC → PRD hierarchy:
|
|
8
|
+
*
|
|
9
|
+
* - Per-project job rows: `<cwd>/session-manager-operations/scheduler/state/queue.json`
|
|
10
|
+
* ({ jobs: [...] } — only that project's jobs)
|
|
11
|
+
* - Per-project history: `<cwd>/session-manager-operations/scheduler/state/history.jsonl`
|
|
12
|
+
* (owned by queueHistory.cjs, path resolved here)
|
|
13
|
+
* - Machine runtime state: `~/.claude/session-manager/scheduler-machine.json`
|
|
14
|
+
* (config, paused/rate-limit, scheduledFor, lastRunAt — these are
|
|
15
|
+
* Session-Manager runtime concerns, like the sessionSlots pool, not any
|
|
16
|
+
* one project's data. Run logs under scheduled-plans/runs/ stay
|
|
17
|
+
* machine-local for the same reason: they're execution artifacts of this
|
|
18
|
+
* machine's runner.)
|
|
19
|
+
*
|
|
20
|
+
* scheduler.cjs's 4k lines keep operating on ONE merged in-memory state
|
|
21
|
+
* object (jobs across all projects + machine fields); this module is the
|
|
22
|
+
* read-merge / write-split shim underneath readQueue/writeQueue. Jobs are
|
|
23
|
+
* split by `job.cwd` (fallback: the provided defaultCwd).
|
|
24
|
+
*
|
|
25
|
+
* Plain Node (no Electron deps) so watchdog scripts can require it; atomic
|
|
26
|
+
* writes are tmp+rename here rather than config.cjs's writeJson because
|
|
27
|
+
* config.cjs requires electron/chokidar and this must load outside the app.
|
|
28
|
+
*/
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const fsp = require('node:fs/promises');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const os = require('node:os');
|
|
35
|
+
const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
|
|
36
|
+
|
|
37
|
+
const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
|
|
38
|
+
const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
|
|
39
|
+
const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
|
|
40
|
+
|
|
41
|
+
function projectStateDir(cwd) {
|
|
42
|
+
if (!cwd || typeof cwd !== 'string') throw new Error('projectStateDir: cwd is required');
|
|
43
|
+
return path.join(cwd, ...STATE_SUBPATH);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function projectQueuePath(cwd) {
|
|
47
|
+
return path.join(projectStateDir(cwd), 'queue.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function projectHistoryPath(cwd) {
|
|
51
|
+
return path.join(projectStateDir(cwd), 'history.jsonl');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeJsonAtomicSync(file, value) {
|
|
55
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
56
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
58
|
+
fs.renameSync(tmp, file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function writeJsonAtomic(file, value) {
|
|
62
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
63
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
64
|
+
await fsp.writeFile(tmp, JSON.stringify(value, null, 2));
|
|
65
|
+
await fsp.rename(tmp, file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- project-cwd enumeration (cached) ----------
|
|
69
|
+
|
|
70
|
+
// allProjectCwds scans ~/.claude/projects; on the readQueue hot path (every
|
|
71
|
+
// IPC status call) that's too much stat traffic, so cache the resolved cwd
|
|
72
|
+
// list briefly. Correctness fallback: a brand-new project appears at worst
|
|
73
|
+
// CACHE_MS late, and its first write goes through writeSplit which busts the
|
|
74
|
+
// cache.
|
|
75
|
+
const CACHE_MS = 30_000;
|
|
76
|
+
let cwdCache = { at: 0, cwds: [] };
|
|
77
|
+
|
|
78
|
+
function stateCwds(opts) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (!opts && now - cwdCache.at < CACHE_MS) return cwdCache.cwds;
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const cwds = [];
|
|
83
|
+
const add = (cwd) => { if (cwd && !seen.has(cwd)) { seen.add(cwd); cwds.push(cwd); } };
|
|
84
|
+
// Projects that already have a state file are authoritative sources...
|
|
85
|
+
for (const cwd of allProjectCwds(opts)) {
|
|
86
|
+
try { if (fs.existsSync(projectQueuePath(cwd))) add(cwd); } catch { /* skip */ }
|
|
87
|
+
}
|
|
88
|
+
// ...and active projects are included even before their first write.
|
|
89
|
+
for (const cwd of activeProjectCwds(undefined, opts)) add(cwd);
|
|
90
|
+
if (!opts) cwdCache = { at: now, cwds };
|
|
91
|
+
return cwds;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bustCwdCache() {
|
|
95
|
+
cwdCache = { at: 0, cwds: [] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- merged read ----------
|
|
99
|
+
|
|
100
|
+
function shapeMachine(raw) {
|
|
101
|
+
const data = raw ? JSON.parse(raw) : {};
|
|
102
|
+
return {
|
|
103
|
+
config: data.config || {},
|
|
104
|
+
scheduledFor: data.scheduledFor ?? null,
|
|
105
|
+
lastRunAt: data.lastRunAt ?? null,
|
|
106
|
+
paused: data.paused ?? null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shapeJobs(raw) {
|
|
111
|
+
const data = JSON.parse(raw);
|
|
112
|
+
return Array.isArray(data.jobs) ? data.jobs : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* readMergedSync(opts?) → { config, jobs, scheduledFor, lastRunAt, paused,
|
|
117
|
+
* unreadable?, unreadablePath?, sourceCwds }.
|
|
118
|
+
*
|
|
119
|
+
* `unreadable` mirrors the old single-file semantics: ANY source file that
|
|
120
|
+
* exists but fails to parse halts scheduling (never treat a project's queue
|
|
121
|
+
* as empty because it read corrupt). `sourceCwds` records every project file
|
|
122
|
+
* consulted so writeSplit can persist "this project now has zero jobs".
|
|
123
|
+
*/
|
|
124
|
+
function readMergedSync(opts) {
|
|
125
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
126
|
+
const sourceCwds = [];
|
|
127
|
+
try {
|
|
128
|
+
Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e?.code !== 'ENOENT') {
|
|
131
|
+
out.unreadable = `machine state unreadable: ${e?.message}`;
|
|
132
|
+
out.unreadablePath = MACHINE_STATE_PATH;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const cwd of stateCwds(opts)) {
|
|
136
|
+
const file = projectQueuePath(cwd);
|
|
137
|
+
try {
|
|
138
|
+
out.jobs.push(...shapeJobs(fs.readFileSync(file, 'utf8')));
|
|
139
|
+
sourceCwds.push(cwd);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
|
|
142
|
+
out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
|
|
143
|
+
out.unreadablePath = out.unreadablePath || file;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
defineSources(out, sourceCwds);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Async twin of readMergedSync for IPC hot paths. */
|
|
151
|
+
async function readMerged(opts) {
|
|
152
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
153
|
+
const sourceCwds = [];
|
|
154
|
+
try {
|
|
155
|
+
Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (e?.code !== 'ENOENT') {
|
|
158
|
+
out.unreadable = `machine state unreadable: ${e?.message}`;
|
|
159
|
+
out.unreadablePath = MACHINE_STATE_PATH;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const cwd of stateCwds(opts)) {
|
|
163
|
+
const file = projectQueuePath(cwd);
|
|
164
|
+
try {
|
|
165
|
+
out.jobs.push(...shapeJobs(await fsp.readFile(file, 'utf8')));
|
|
166
|
+
sourceCwds.push(cwd);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
|
|
169
|
+
out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
|
|
170
|
+
out.unreadablePath = out.unreadablePath || file;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
defineSources(out, sourceCwds);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Non-enumerable so broadcast/JSON payloads of the state never carry it.
|
|
178
|
+
function defineSources(state, sourceCwds) {
|
|
179
|
+
Object.defineProperty(state, 'sourceCwds', {
|
|
180
|
+
value: sourceCwds, enumerable: false, configurable: true, writable: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------- split write ----------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* writeSplit(state, defaultCwd) — persist a merged state back to its shards:
|
|
188
|
+
* machine fields → MACHINE_STATE_PATH; jobs grouped by job.cwd (fallback
|
|
189
|
+
* defaultCwd) → each project's state/queue.json. Every cwd the read consulted
|
|
190
|
+
* (state.sourceCwds) is written even when it now holds zero jobs, so
|
|
191
|
+
* deletions stick.
|
|
192
|
+
*/
|
|
193
|
+
async function writeSplit(state, defaultCwd) {
|
|
194
|
+
await writeJsonAtomic(MACHINE_STATE_PATH, {
|
|
195
|
+
config: state.config,
|
|
196
|
+
scheduledFor: state.scheduledFor ?? null,
|
|
197
|
+
lastRunAt: state.lastRunAt ?? null,
|
|
198
|
+
paused: state.paused ?? null,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const byCwd = new Map();
|
|
202
|
+
for (const cwd of state.sourceCwds ?? []) byCwd.set(cwd, []);
|
|
203
|
+
for (const job of state.jobs ?? []) {
|
|
204
|
+
const cwd = job.cwd || defaultCwd;
|
|
205
|
+
if (!cwd) continue; // nowhere to put it; job is dropped from persistence rather than crashing
|
|
206
|
+
if (!byCwd.has(cwd)) byCwd.set(cwd, []);
|
|
207
|
+
byCwd.get(cwd).push(job);
|
|
208
|
+
}
|
|
209
|
+
for (const [cwd, jobs] of byCwd) {
|
|
210
|
+
try {
|
|
211
|
+
await writeJsonAtomic(projectQueuePath(cwd), { jobs });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// A single unwritable project (deleted repo dir, permissions) must not
|
|
214
|
+
// lose every other project's write.
|
|
215
|
+
console.error(`[queueStore] failed to write ${projectQueuePath(cwd)}: ${e?.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
bustCwdCache();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- legacy migration ----------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* migrateLegacyGlobalQueue(defaultCwd) — one-time boot split of the retired
|
|
225
|
+
* global queue.json into per-project shards. Shard rows win over legacy rows
|
|
226
|
+
* with the same slug (the shard is newer by construction). The legacy file is
|
|
227
|
+
* renamed to `queue.json.retired-<epoch>` so a rollback can recover it but no
|
|
228
|
+
* reader ever consults it again. Machine fields (config/paused/...) migrate
|
|
229
|
+
* only when no machine file exists yet. Idempotent: no legacy file → no-op.
|
|
230
|
+
*/
|
|
231
|
+
async function migrateLegacyGlobalQueue(defaultCwd) {
|
|
232
|
+
let raw;
|
|
233
|
+
try {
|
|
234
|
+
raw = await fsp.readFile(LEGACY_QUEUE_PATH, 'utf8');
|
|
235
|
+
} catch {
|
|
236
|
+
return { migrated: false };
|
|
237
|
+
}
|
|
238
|
+
let legacy;
|
|
239
|
+
try {
|
|
240
|
+
legacy = JSON.parse(raw);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error(`[queueStore] legacy queue.json unparseable — leaving in place: ${e?.message}`);
|
|
243
|
+
return { migrated: false, error: e?.message };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!fs.existsSync(MACHINE_STATE_PATH)) {
|
|
247
|
+
await writeJsonAtomic(MACHINE_STATE_PATH, {
|
|
248
|
+
config: legacy.config || {},
|
|
249
|
+
scheduledFor: legacy.scheduledFor ?? null,
|
|
250
|
+
lastRunAt: legacy.lastRunAt ?? null,
|
|
251
|
+
paused: legacy.paused ?? null,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const legacyJobs = Array.isArray(legacy.jobs) ? legacy.jobs : [];
|
|
256
|
+
const byCwd = new Map();
|
|
257
|
+
for (const job of legacyJobs) {
|
|
258
|
+
const cwd = job.cwd || defaultCwd;
|
|
259
|
+
if (!cwd) continue;
|
|
260
|
+
if (!byCwd.has(cwd)) byCwd.set(cwd, []);
|
|
261
|
+
byCwd.get(cwd).push(job);
|
|
262
|
+
}
|
|
263
|
+
let moved = 0;
|
|
264
|
+
for (const [cwd, jobs] of byCwd) {
|
|
265
|
+
const file = projectQueuePath(cwd);
|
|
266
|
+
let existing = [];
|
|
267
|
+
try { existing = shapeJobs(await fsp.readFile(file, 'utf8')); } catch { /* fresh shard */ }
|
|
268
|
+
const have = new Set(existing.map((j) => j.slug));
|
|
269
|
+
const merged = [...existing, ...jobs.filter((j) => !have.has(j.slug))];
|
|
270
|
+
try {
|
|
271
|
+
await writeJsonAtomic(file, { jobs: merged });
|
|
272
|
+
moved += merged.length - existing.length;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
console.error(`[queueStore] legacy split: failed to write ${file}: ${e?.message}`);
|
|
275
|
+
return { migrated: false, error: e?.message };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await fsp.rename(LEGACY_QUEUE_PATH, `${LEGACY_QUEUE_PATH}.retired-${Date.now()}`);
|
|
280
|
+
bustCwdCache();
|
|
281
|
+
return { migrated: true, moved, projects: byCwd.size };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
MACHINE_STATE_PATH,
|
|
286
|
+
LEGACY_QUEUE_PATH,
|
|
287
|
+
STATE_SUBPATH,
|
|
288
|
+
projectStateDir,
|
|
289
|
+
projectQueuePath,
|
|
290
|
+
projectHistoryPath,
|
|
291
|
+
stateCwds,
|
|
292
|
+
bustCwdCache,
|
|
293
|
+
readMerged,
|
|
294
|
+
readMergedSync,
|
|
295
|
+
writeSplit,
|
|
296
|
+
migrateLegacyGlobalQueue,
|
|
297
|
+
writeJsonAtomic,
|
|
298
|
+
writeJsonAtomicSync,
|
|
299
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessionSlots.cjs — the Session-Manager-owned machine-wide `claude -p`
|
|
3
|
+
* concurrency pool (2026-07-31 domain-model decision).
|
|
4
|
+
*
|
|
5
|
+
* Caps and limits belong to Session-Manager, not to any one consumer: the
|
|
6
|
+
* scheduler and chatRunner previously each enforced a private cap (3 and 2),
|
|
7
|
+
* which combined could exceed the machine's real budget — the exact shape of
|
|
8
|
+
* the 2026-06-10 five-parallel-`claude -p` OOM. Now every subsystem that
|
|
9
|
+
* wants to launch a `claude -p` process REQUESTS a slot here first and
|
|
10
|
+
* releases it when the process settles. There is one pool, sized to the
|
|
11
|
+
* machine (default 3 — CLAUDE.md "Avoid" cap; SM_SESSION_SLOTS overrides,
|
|
12
|
+
* clamped to [1, 3]).
|
|
13
|
+
*
|
|
14
|
+
* Consumers keep their own scheduling policy (FIFO lanes, batch picking,
|
|
15
|
+
* memory gates); this module only answers "may one more process start right
|
|
16
|
+
* now?". Plain Node, no Electron deps, process-local state — all consumers
|
|
17
|
+
* live in the one Electron main process, which is exactly why it can be the
|
|
18
|
+
* arbiter.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const crypto = require('node:crypto');
|
|
23
|
+
|
|
24
|
+
function totalSlots() {
|
|
25
|
+
const parsed = parseInt(process.env.SM_SESSION_SLOTS || '3', 10);
|
|
26
|
+
return Math.min(3, Math.max(1, Number.isFinite(parsed) ? parsed : 3));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// token → { owner, at }
|
|
30
|
+
const holders = new Map();
|
|
31
|
+
|
|
32
|
+
function inUse() {
|
|
33
|
+
return holders.size;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function available() {
|
|
37
|
+
return Math.max(0, totalSlots() - holders.size);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* acquire(owner) → token string, or null when the pool is exhausted.
|
|
42
|
+
* `owner` is a diagnostic label ("scheduler:<slug>", "chat:<tabId>") shown in
|
|
43
|
+
* snapshot() so a stuck holder is attributable.
|
|
44
|
+
*/
|
|
45
|
+
function acquire(owner) {
|
|
46
|
+
if (holders.size >= totalSlots()) return null;
|
|
47
|
+
const token = crypto.randomUUID();
|
|
48
|
+
holders.set(token, { owner: String(owner || 'unknown'), at: new Date().toISOString() });
|
|
49
|
+
return token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Release listeners: each consumer registers its own "a slot freed — try to
|
|
53
|
+
// start work" pump so a scheduler release wakes the chat lane and vice versa.
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
function subscribe(fn) {
|
|
56
|
+
listeners.add(fn);
|
|
57
|
+
return () => listeners.delete(fn);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** release(token) — idempotent; releasing an unknown/already-released token is a no-op. */
|
|
61
|
+
function release(token) {
|
|
62
|
+
const had = holders.delete(token);
|
|
63
|
+
if (had) {
|
|
64
|
+
for (const fn of listeners) {
|
|
65
|
+
try { fn(); } catch { /* a consumer's pump error is its own problem */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return had;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Diagnostic view for status surfaces and tests. */
|
|
72
|
+
function snapshot() {
|
|
73
|
+
return {
|
|
74
|
+
total: totalSlots(),
|
|
75
|
+
inUse: holders.size,
|
|
76
|
+
holders: [...holders.values()],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Test hook: drop all held slots. */
|
|
81
|
+
function __resetForTests() {
|
|
82
|
+
holders.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { totalSlots, inUse, available, acquire, release, subscribe, snapshot, __resetForTests };
|
package/src/main/queueOps.cjs
CHANGED
|
@@ -307,6 +307,16 @@ async function archiveOne(slug, archiveDir) {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Injected by index.cjs at registration time (see registerQueueOpsHandlers)
|
|
312
|
+
* so a manual archive can retire any still-runnable queue job for the same
|
|
313
|
+
* slug without queueOps.cjs importing scheduler.cjs (circular — scheduler.cjs
|
|
314
|
+
* already requires queueOps.cjs). No-op until set; the auto-archive path
|
|
315
|
+
* never needs it (selectAutoArchivable only ever selects already-completed
|
|
316
|
+
* jobs).
|
|
317
|
+
*/
|
|
318
|
+
let retireCompletedSlugsFn = async () => {};
|
|
319
|
+
|
|
310
320
|
async function archiveMany(slugs) {
|
|
311
321
|
if (!Array.isArray(slugs) || slugs.length === 0) {
|
|
312
322
|
return { ok: true, archived: 0, archivedTo: null, results: [] };
|
|
@@ -324,6 +334,12 @@ async function archiveMany(slugs) {
|
|
|
324
334
|
results.push(await archiveOne(slug, archiveDir));
|
|
325
335
|
}
|
|
326
336
|
const archived = results.filter((r) => r.ok).length;
|
|
337
|
+
const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
|
338
|
+
if (archivedSlugs.length > 0) {
|
|
339
|
+
await retireCompletedSlugsFn(archivedSlugs).catch((e) => {
|
|
340
|
+
logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
|
|
341
|
+
});
|
|
342
|
+
}
|
|
327
343
|
return { ok: true, archived, archivedTo: archiveDir, results };
|
|
328
344
|
}
|
|
329
345
|
|
|
@@ -547,7 +563,10 @@ async function retagMany(items) {
|
|
|
547
563
|
|
|
548
564
|
// ────────────────────────────────────────────── IPC registration
|
|
549
565
|
|
|
550
|
-
function registerQueueOpsHandlers() {
|
|
566
|
+
function registerQueueOpsHandlers({ retireCompletedSlugs } = {}) {
|
|
567
|
+
if (typeof retireCompletedSlugs === 'function') {
|
|
568
|
+
retireCompletedSlugsFn = retireCompletedSlugs;
|
|
569
|
+
}
|
|
551
570
|
ipcMain.handle('schedule:lint-queue', async () => {
|
|
552
571
|
return lintAll();
|
|
553
572
|
});
|