claude-code-session-manager 0.20.0 → 0.20.1
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-COZHDXvn.js → TiptapBody-Db7_uXrI.js} +1 -1
- package/dist/assets/{cssMode-BGlgF50F.js → cssMode-DFKJhhi6.js} +1 -1
- package/dist/assets/{freemarker2-CwlJczaA.js → freemarker2-DUat8x8o.js} +1 -1
- package/dist/assets/{handlebars-C7ChleGP.js → handlebars-B2C1qhAI.js} +1 -1
- package/dist/assets/{html-C0XyedAq.js → html-khtg0DVs.js} +1 -1
- package/dist/assets/{htmlMode-DTJsOfuO.js → htmlMode-Jmhs-vfl.js} +1 -1
- package/dist/assets/{index-6poesY86.css → index-BkkBX1z7.css} +1 -1
- package/dist/assets/{index-C4joLNKY.js → index-pqnuXM14.js} +588 -578
- package/dist/assets/{javascript-CPRB5GUm.js → javascript-i1CXbgg4.js} +1 -1
- package/dist/assets/{jsonMode-DKBN0s8-.js → jsonMode-DXZaj-kR.js} +1 -1
- package/dist/assets/{liquid-CJmNIgnK.js → liquid-Ds7jUF53.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-CIIba3v8.js → lspLanguageFeatures-B_15vO6X.js} +1 -1
- package/dist/assets/{mdx-BOiNk1a1.js → mdx-DgrrLgTE.js} +1 -1
- package/dist/assets/{python-5AV3HPYJ.js → python-Cff3tPw3.js} +1 -1
- package/dist/assets/{razor-6iMJA6dH.js → razor-DlyG7FmM.js} +1 -1
- package/dist/assets/{tsMode-WJISqg3-.js → tsMode-DRmmmttS.js} +1 -1
- package/dist/assets/{typescript-CnA0yZf9.js → typescript-DQFL2T1p.js} +1 -1
- package/dist/assets/{xml-BLkNwYO2.js → xml-CwsJEzdU.js} +1 -1
- package/dist/assets/{yaml-D6anZ1nO.js → yaml-BDsDjf-y.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/main/historyAggregator.cjs +15 -9
- package/src/main/index.cjs +7 -2
- package/src/main/ipcSchemas.cjs +43 -0
- package/src/main/kg.cjs +27 -17
- package/src/main/lib/reaperHelpers.cjs +67 -0
- package/src/main/lib/schedulerBatch.cjs +212 -0
- package/src/main/scheduler.cjs +173 -125
- package/src/main/webRemote.cjs +916 -0
- package/src/preload/api.d.ts +50 -9
- package/src/preload/index.cjs +34 -5
- package/src/main/projectSkills.cjs +0 -124
package/src/main/index.cjs
CHANGED
|
@@ -35,11 +35,11 @@ const { registerDocEditorHandlers } = require('./docEditor.cjs');
|
|
|
35
35
|
const git = require('./git.cjs');
|
|
36
36
|
const superagent = require('./superagent.cjs');
|
|
37
37
|
const kg = require('./kg.cjs');
|
|
38
|
-
const { registerProjectSkillsHandlers } = require('./projectSkills.cjs');
|
|
39
38
|
const filesIpc = require('./files.cjs');
|
|
40
39
|
const searchIpc = require('./search.cjs');
|
|
41
40
|
const repoAnalyzer = require('./repoAnalyzer.cjs');
|
|
42
41
|
const hivesIpc = require('./hives.cjs');
|
|
42
|
+
const webRemote = require('./webRemote.cjs');
|
|
43
43
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
44
44
|
const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
|
|
45
45
|
const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
|
|
@@ -626,11 +626,11 @@ agentMemory.registerAgentMemoryHandlers();
|
|
|
626
626
|
registerDocEditorHandlers();
|
|
627
627
|
git.register(ipcMain);
|
|
628
628
|
superagent.registerSuperAgentHandlers();
|
|
629
|
-
registerProjectSkillsHandlers();
|
|
630
629
|
filesIpc.registerFilesHandlers();
|
|
631
630
|
searchIpc.registerSearchHandlers();
|
|
632
631
|
repoAnalyzer.register(ipcMain);
|
|
633
632
|
hivesIpc.registerHiveHandlers();
|
|
633
|
+
webRemote.registerRemoteHandlers();
|
|
634
634
|
|
|
635
635
|
// OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
|
|
636
636
|
ipcMain.handle('otel:get-config', async () => otelSettings.load());
|
|
@@ -918,9 +918,13 @@ app.whenReady().then(async () => {
|
|
|
918
918
|
pluginInstall.attachWindow(mainWindow);
|
|
919
919
|
superagent.attachWindow(mainWindow);
|
|
920
920
|
kg.attachWindow(mainWindow);
|
|
921
|
+
webRemote.attachWindow(mainWindow);
|
|
921
922
|
scheduler.init().catch((e) => {
|
|
922
923
|
logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
923
924
|
});
|
|
925
|
+
webRemote.init().catch((e) => {
|
|
926
|
+
logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
927
|
+
});
|
|
924
928
|
// Knowledge Graph: watch the prompt log + register kg:* IPC. Best-effort.
|
|
925
929
|
try { kg.init({ logger: logs }); } catch (e) {
|
|
926
930
|
logs.writeLine({ scope: 'kg', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
@@ -964,6 +968,7 @@ let teardownDone = false;
|
|
|
964
968
|
function runShutdownCleanup() {
|
|
965
969
|
if (teardownDone) return; // idempotent — will-quit may still fire after an app.exit path
|
|
966
970
|
teardownDone = true;
|
|
971
|
+
webRemote.destroy();
|
|
967
972
|
// Mark a clean exit so the next boot can distinguish a graceful quit from an
|
|
968
973
|
// OOM-kill / native crash (which leaves the sentinel `open`).
|
|
969
974
|
crashDiagnostics.markCleanShutdown();
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -242,6 +242,23 @@ const agentMemoryDelete = z.object({
|
|
|
242
242
|
entryId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
243
243
|
}).strict();
|
|
244
244
|
|
|
245
|
+
// ──────────────────────────────────────────── Web Remote
|
|
246
|
+
// OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
|
|
247
|
+
const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
|
|
248
|
+
const DEVICE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
249
|
+
|
|
250
|
+
const webRemotePair = z.object({
|
|
251
|
+
otp: z.string().regex(WEB_REMOTE_OTP_RE),
|
|
252
|
+
}).strict();
|
|
253
|
+
|
|
254
|
+
const webRemoteRevokeDevice = z.object({
|
|
255
|
+
deviceId: z.string().regex(DEVICE_ID_RE),
|
|
256
|
+
}).strict();
|
|
257
|
+
|
|
258
|
+
const webRemoteAuditTail = z.object({
|
|
259
|
+
lines: z.number().int().min(1).max(500).optional(),
|
|
260
|
+
}).strict();
|
|
261
|
+
|
|
245
262
|
// ──────────────────────────────────────────── History
|
|
246
263
|
const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
|
|
247
264
|
|
|
@@ -365,12 +382,38 @@ function validated(schema, handler) {
|
|
|
365
382
|
};
|
|
366
383
|
}
|
|
367
384
|
|
|
385
|
+
// ──────────────────────────────────────────── Web Remote command allowlist
|
|
386
|
+
// Single source of truth — imported by webRemote.cjs and by the unit test.
|
|
387
|
+
// Only these type strings will ever reach a handler; all others are silently
|
|
388
|
+
// dropped without leaking error details back to the relay (ADR §6.2).
|
|
389
|
+
const ALLOWED_COMMANDS = new Set([
|
|
390
|
+
'cmd:sessions:load',
|
|
391
|
+
'cmd:sessions:save',
|
|
392
|
+
'cmd:pty:spawn',
|
|
393
|
+
'cmd:pty:write',
|
|
394
|
+
'cmd:pty:resize',
|
|
395
|
+
'cmd:pty:kill',
|
|
396
|
+
'cmd:schedule:state',
|
|
397
|
+
'cmd:schedule:read-prd',
|
|
398
|
+
'cmd:schedule:read-log',
|
|
399
|
+
'cmd:schedule:write-prd',
|
|
400
|
+
'cmd:schedule:reset-job',
|
|
401
|
+
'cmd:schedule:run-now',
|
|
402
|
+
'cmd:schedule:set-config',
|
|
403
|
+
'cmd:history:aggregate',
|
|
404
|
+
'cmd:app:version',
|
|
405
|
+
]);
|
|
406
|
+
|
|
368
407
|
module.exports = {
|
|
369
408
|
// Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
|
|
370
409
|
// direct test()/match() containment checks alongside the zod parses.
|
|
371
410
|
SCHEDULE_SLUG_RE,
|
|
372
411
|
SCHEDULE_RUN_ID_RE,
|
|
412
|
+
ALLOWED_COMMANDS,
|
|
373
413
|
schemas: {
|
|
414
|
+
webRemotePair,
|
|
415
|
+
webRemoteRevokeDevice,
|
|
416
|
+
webRemoteAuditTail,
|
|
374
417
|
ptySpawn,
|
|
375
418
|
ptyTabId,
|
|
376
419
|
ptyWrite,
|
package/src/main/kg.cjs
CHANGED
|
@@ -312,7 +312,7 @@ async function ingest() {
|
|
|
312
312
|
const units = planUnits(buf.toString('utf8'));
|
|
313
313
|
if (!units) { broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: 0 }); return { ok: true, added: 0 }; }
|
|
314
314
|
|
|
315
|
-
const graphs = new Map(); // encodedCwd -> graph (lazy-loaded
|
|
315
|
+
const graphs = new Map(); // encodedCwd -> graph (lazy-loaded; persisted per batch)
|
|
316
316
|
async function graphFor(cwd) {
|
|
317
317
|
const enc = encodeCwd(cwd);
|
|
318
318
|
if (!graphs.has(enc)) graphs.set(enc, await loadGraphFor(cwd));
|
|
@@ -320,15 +320,23 @@ async function ingest() {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
const totalBatches = units.filter((u) => u.type === 'batch').length;
|
|
323
|
-
let committedBytes = 0;
|
|
324
323
|
let committedPrompts = 0;
|
|
325
324
|
let added = 0;
|
|
326
|
-
let lastTs = st.lastTs;
|
|
327
325
|
let batchNo = 0;
|
|
328
326
|
let failed = false;
|
|
327
|
+
const touched = new Set(); // encodedCwds whose graph changed this run
|
|
329
328
|
|
|
329
|
+
// Each iteration COMMITS before moving on: persist the touched graph, then
|
|
330
|
+
// advance the global byte-watermark past exactly this unit. Because units
|
|
331
|
+
// are processed in log order, the watermark stays a correct contiguous
|
|
332
|
+
// boundary — a crash, quit, or rate-limit mid-run loses at most the batch
|
|
333
|
+
// in flight, and the graph grows live as each batch lands.
|
|
330
334
|
for (const u of units) {
|
|
331
|
-
if (u.type === 'skip') {
|
|
335
|
+
if (u.type === 'skip') {
|
|
336
|
+
st.lastOffset += u.bytes;
|
|
337
|
+
await saveIngestState(st);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
332
340
|
batchNo++;
|
|
333
341
|
broadcast('kg:ingest-progress', { phase: 'extract', ingesting: true, batch: batchNo, totalBatches });
|
|
334
342
|
|
|
@@ -342,28 +350,30 @@ async function ingest() {
|
|
|
342
350
|
const parsed = extractJson(r.out);
|
|
343
351
|
if (!parsed) { logger.writeLine({ scope: 'kg', level: 'warn', message: 'extraction unparseable; stopping (resumable)', meta: { cwd: u.cwd } }); failed = true; break; }
|
|
344
352
|
|
|
345
|
-
const batchTs = u.entries[u.entries.length - 1].ts || lastTs || new Date().toISOString();
|
|
353
|
+
const batchTs = u.entries[u.entries.length - 1].ts || st.lastTs || new Date().toISOString();
|
|
346
354
|
for (const ent of (parsed.entities || [])) { if (upsertNode(byKey, g, ent, batchTs)) added++; }
|
|
347
355
|
for (const rel of (parsed.relations || [])) { upsertEdge(byEdge, g, canonicalize(rel.src), canonicalize(rel.dst), rel.relation, batchTs); }
|
|
348
356
|
g.promptCount += u.entries.length;
|
|
349
357
|
g.updatedAt = new Date().toISOString();
|
|
350
358
|
|
|
351
|
-
|
|
359
|
+
// Commit this batch: graph first (so a crash can't advance the watermark
|
|
360
|
+
// past unsaved work), then the watermark.
|
|
361
|
+
await saveGraph(g);
|
|
362
|
+
st.lastOffset += u.bytes;
|
|
363
|
+
st.promptCount += u.entries.length;
|
|
364
|
+
st.lastTs = batchTs;
|
|
365
|
+
st.updatedAt = new Date().toISOString();
|
|
366
|
+
await saveIngestState(st);
|
|
367
|
+
|
|
352
368
|
committedPrompts += u.entries.length;
|
|
353
|
-
|
|
369
|
+
touched.add(encodeCwd(u.cwd));
|
|
370
|
+
// Tell the renderer this batch landed so it can refresh the graph live.
|
|
371
|
+
broadcast('kg:ingest-progress', { phase: 'batch', ingesting: true, batch: batchNo, totalBatches, cwd: u.cwd, added });
|
|
354
372
|
}
|
|
355
373
|
|
|
356
|
-
|
|
357
|
-
for (const g of graphs.values()) await saveGraph(g);
|
|
358
|
-
st.lastOffset += committedBytes;
|
|
359
|
-
st.promptCount += committedPrompts;
|
|
360
|
-
st.lastTs = lastTs;
|
|
361
|
-
st.updatedAt = new Date().toISOString();
|
|
362
|
-
await saveIngestState(st);
|
|
363
|
-
|
|
364
|
-
logger.writeLine({ scope: 'kg', level: 'info', message: 'ingest complete', meta: { committedPrompts, projects: graphs.size, stopped: failed } });
|
|
374
|
+
logger.writeLine({ scope: 'kg', level: 'info', message: 'ingest complete', meta: { committedPrompts, projects: touched.size, stopped: failed } });
|
|
365
375
|
broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: committedPrompts });
|
|
366
|
-
return { ok: true, added: committedPrompts, projects:
|
|
376
|
+
return { ok: true, added: committedPrompts, projects: touched.size, stopped: failed };
|
|
367
377
|
} catch (e) {
|
|
368
378
|
logger.writeLine({ scope: 'kg', level: 'error', message: 'ingest error', meta: { error: e?.message } });
|
|
369
379
|
broadcast('kg:ingest-progress', { phase: 'error', ingesting: false, error: e?.message });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* reaperHelpers.cjs — pure helpers for the dead-process reaper in scheduler.cjs.
|
|
5
|
+
*
|
|
6
|
+
* Kept in a separate lib file so they can be unit-tested without importing
|
|
7
|
+
* scheduler.cjs (which requires electron/ipcMain).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const { readTail } = require('./fileTail.cjs');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return true if pid is alive AND its cmdline looks like a claude process.
|
|
15
|
+
*
|
|
16
|
+
* Guards against PID recycling: on Linux we read /proc/<pid>/cmdline and
|
|
17
|
+
* require /\bclaude\b/ in the command. On macOS (no /proc) we can't read
|
|
18
|
+
* cmdline, so we conservatively return true — never false-reap a live PID
|
|
19
|
+
* just because we can't verify its identity.
|
|
20
|
+
*
|
|
21
|
+
* Conservative by design: a false negative (live process treated as dead) is
|
|
22
|
+
* far worse than a late reap.
|
|
23
|
+
*/
|
|
24
|
+
function claudePidAlive(pid) {
|
|
25
|
+
if (!pid || typeof pid !== 'number' || pid <= 1) return false;
|
|
26
|
+
try { process.kill(pid, 0); } catch { return false; }
|
|
27
|
+
try {
|
|
28
|
+
const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
29
|
+
return /\bclaude\b/.test(cmd);
|
|
30
|
+
} catch {
|
|
31
|
+
// Can't read cmdline (macOS, permission denied) → assume alive.
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Classify the terminal outcome of a completed run by reading the last 64 KB
|
|
38
|
+
* of its log file and scanning for the LAST `{"type":"result"}` JSONL event.
|
|
39
|
+
*
|
|
40
|
+
* Returns:
|
|
41
|
+
* 'success' — last result event has subtype=success and is_error !== true
|
|
42
|
+
* 'failed' — last result event exists but indicates an error
|
|
43
|
+
* 'no_result' — no result event found in the tail (process may have been killed
|
|
44
|
+
* before emitting one, or the log is absent/empty)
|
|
45
|
+
* 'unknown' — unexpected error reading/parsing (outer catch)
|
|
46
|
+
*/
|
|
47
|
+
function classifyRunOutcome(logPath) {
|
|
48
|
+
try {
|
|
49
|
+
const text = readTail(logPath, 65536);
|
|
50
|
+
let lastResult = null;
|
|
51
|
+
for (const line of text.split('\n')) {
|
|
52
|
+
const t = line.trim();
|
|
53
|
+
if (!t.startsWith('{')) continue;
|
|
54
|
+
try {
|
|
55
|
+
const obj = JSON.parse(t);
|
|
56
|
+
if (obj && obj.type === 'result') lastResult = obj;
|
|
57
|
+
} catch { /* partial line at tail boundary or non-JSON scheduler log line */ }
|
|
58
|
+
}
|
|
59
|
+
if (!lastResult) return 'no_result';
|
|
60
|
+
if (lastResult.subtype === 'success' && lastResult.is_error !== true) return 'success';
|
|
61
|
+
return 'failed';
|
|
62
|
+
} catch {
|
|
63
|
+
return 'unknown';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { claudePidAlive, classifyRunOutcome };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* schedulerBatch.cjs — pure batch-picking logic for the scheduler.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from scheduler.cjs so the functions can be unit-tested without
|
|
7
|
+
* loading the full scheduler (which requires electron + heavy I/O).
|
|
8
|
+
*
|
|
9
|
+
* Group-ordering gates (failure-gate, running-gate) are evaluated
|
|
10
|
+
* PER PROJECT (keyed by cwd). Jobs in different projects do not serialize
|
|
11
|
+
* each other. Within a single project, the sequential-group semantics are
|
|
12
|
+
* fully preserved.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-project batch picker. Applies group-ordering rules scoped to a single
|
|
22
|
+
* project (all jobs sharing one cwd).
|
|
23
|
+
*
|
|
24
|
+
* Rules (same as original global pickNextBatch, but scoped):
|
|
25
|
+
* 1. Find the lowest parallelGroup with pending jobs not already running.
|
|
26
|
+
* 2. Failure gate: if an earlier group has failed jobs, hold this project.
|
|
27
|
+
* 3. If that group has jobs in flight (backfill), fire more from SAME group.
|
|
28
|
+
* 4. If a lower-numbered group arrives late (late-arrival), fire it now.
|
|
29
|
+
* 5. If no group is in flight, start the lowest pending group fresh.
|
|
30
|
+
*
|
|
31
|
+
* @param {object[]} projectJobs - All jobs for this project (all statuses).
|
|
32
|
+
* @param {Set<string>} runningSlugsInProject - Slugs from the global
|
|
33
|
+
* runningSet that belong to this project.
|
|
34
|
+
* @param {number} slots - Maximum jobs to return (global remaining slots;
|
|
35
|
+
* caller enforces the global cap across projects).
|
|
36
|
+
* @returns {object[]} Jobs to spawn for this project this tick.
|
|
37
|
+
*/
|
|
38
|
+
function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
39
|
+
const pending = projectJobs.filter(
|
|
40
|
+
(j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
|
|
41
|
+
);
|
|
42
|
+
if (pending.length === 0) return [];
|
|
43
|
+
|
|
44
|
+
const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
|
|
45
|
+
|
|
46
|
+
// Lowest pending group (computed up-front for the failure-gate check).
|
|
47
|
+
const lowestPendingGroup = pending.reduce(
|
|
48
|
+
(min, j) => Math.min(min, j.parallelGroup ?? 99),
|
|
49
|
+
Infinity,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Cross-group failure gate: refuse to advance past a group with failed jobs.
|
|
53
|
+
// A failed foundation PRD should not allow later groups to run and
|
|
54
|
+
// silently corrupt project state. needs_review is NOT a blocker.
|
|
55
|
+
const blockingFailures = projectJobs.filter(
|
|
56
|
+
(j) => j.status === 'failed' && (j.parallelGroup ?? 99) < lowestPendingGroup,
|
|
57
|
+
);
|
|
58
|
+
if (blockingFailures.length > 0) {
|
|
59
|
+
const slugs = blockingFailures.map((j) => j.slug).join(', ');
|
|
60
|
+
console.log(
|
|
61
|
+
`[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
|
|
62
|
+
`${blockingFailures.length} failed job(s) in earlier groups [${slugs}]. ` +
|
|
63
|
+
`Reset to pending or archive to unblock.`,
|
|
64
|
+
);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Groups with at least one job in flight: either tracked in runningSlugsInProject
|
|
69
|
+
// (this process spawned it) or still marked 'running' in queue.json
|
|
70
|
+
// (persisted from a previous session that hasn't been orphan-reset yet).
|
|
71
|
+
const jobBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
|
|
72
|
+
const activeGroups = new Set();
|
|
73
|
+
for (const slug of runningSlugsInProject) {
|
|
74
|
+
const job = jobBySlug.get(slug);
|
|
75
|
+
if (job) activeGroups.add(job.parallelGroup ?? 99);
|
|
76
|
+
}
|
|
77
|
+
for (const j of projectJobs) {
|
|
78
|
+
if (j.status === 'running' && !runningSlugsInProject.has(j.slug)) {
|
|
79
|
+
activeGroups.add(j.parallelGroup ?? 99);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (activeGroups.size > 0) {
|
|
84
|
+
const lowestActive = Math.min(...activeGroups);
|
|
85
|
+
if (lowestPendingGroup > lowestActive) {
|
|
86
|
+
// Earlier group still running — wait for it to drain before advancing.
|
|
87
|
+
console.log(
|
|
88
|
+
`[scheduler] concurrency [${projectCwd}]: g${lowestActive} in flight, holding g${lowestPendingGroup}`,
|
|
89
|
+
);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
if (lowestPendingGroup < lowestActive) {
|
|
93
|
+
// Late-arrival: a lower-numbered (higher-priority) PRD reconciled AFTER
|
|
94
|
+
// a higher-numbered group was already picked. Fire it now in parallel
|
|
95
|
+
// with the active group rather than starving it until drain.
|
|
96
|
+
if (slots <= 0) {
|
|
97
|
+
console.log(
|
|
98
|
+
`[scheduler] concurrency [${projectCwd}]: no slots for late-arrival g${lowestPendingGroup}`,
|
|
99
|
+
);
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const batch = pending
|
|
103
|
+
.filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
|
|
104
|
+
.slice(0, slots);
|
|
105
|
+
console.log(
|
|
106
|
+
`[scheduler] concurrency [${projectCwd}]: firing late-arrival g${lowestPendingGroup} ` +
|
|
107
|
+
`(${batch.length} job(s)) alongside active g${lowestActive}`,
|
|
108
|
+
);
|
|
109
|
+
return batch;
|
|
110
|
+
}
|
|
111
|
+
// Backfill slots remaining in the current group.
|
|
112
|
+
if (slots <= 0) {
|
|
113
|
+
console.log(`[scheduler] concurrency [${projectCwd}]: cap reached, no slots`);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const batch = pending
|
|
117
|
+
.filter((j) => (j.parallelGroup ?? 99) === lowestActive)
|
|
118
|
+
.slice(0, slots);
|
|
119
|
+
if (batch.length > 0) {
|
|
120
|
+
console.log(
|
|
121
|
+
`[scheduler] concurrency [${projectCwd}]: backfilling ${batch.length} into g${lowestActive}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return batch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// No active group — start the next group fresh.
|
|
128
|
+
if (slots <= 0) {
|
|
129
|
+
console.log(`[scheduler] concurrency [${projectCwd}]: cap reached, no slots`);
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const batch = pending
|
|
133
|
+
.filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
|
|
134
|
+
.slice(0, slots);
|
|
135
|
+
console.log(
|
|
136
|
+
`[scheduler] concurrency [${projectCwd}]: starting g${lowestPendingGroup} with ${batch.length} job(s)`,
|
|
137
|
+
);
|
|
138
|
+
return batch;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pick the next batch of jobs to spawn this tick.
|
|
143
|
+
*
|
|
144
|
+
* Group-ordering gates are evaluated PER PROJECT (keyed by cwd), so jobs in
|
|
145
|
+
* different projects are not serialized by each other's groups. Within a
|
|
146
|
+
* single project, the existing sequential-group semantics are fully preserved.
|
|
147
|
+
*
|
|
148
|
+
* O(N) where N = allJobs.length.
|
|
149
|
+
*
|
|
150
|
+
* @param {object[]} allJobs - Full queue.json job list.
|
|
151
|
+
* @param {Set<string>} running - In-process running slugs (runningSet).
|
|
152
|
+
* @param {number} cap - concurrencyCap.
|
|
153
|
+
* @returns {object[]} Jobs to spawn this tick.
|
|
154
|
+
*/
|
|
155
|
+
function pickNextBatch(allJobs, running, cap) {
|
|
156
|
+
if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) return [];
|
|
157
|
+
|
|
158
|
+
// Global slot accounting: take the higher of in-process running count and
|
|
159
|
+
// queue.json running count (handles orphaned running entries from a previous
|
|
160
|
+
// session not yet reaped).
|
|
161
|
+
const queueRunningCount = allJobs.filter((j) => j.status === 'running').length;
|
|
162
|
+
const effectiveRunning = Math.max(running.size, queueRunningCount);
|
|
163
|
+
let slots = cap - effectiveRunning;
|
|
164
|
+
if (slots <= 0) {
|
|
165
|
+
console.log(
|
|
166
|
+
`[scheduler] concurrency: cap ${cap} reached (${effectiveRunning} running), no slots`,
|
|
167
|
+
);
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Group all jobs by project cwd.
|
|
172
|
+
const projectMap = new Map();
|
|
173
|
+
for (const job of allJobs) {
|
|
174
|
+
const key = job.cwd || DEFAULT_PROJECT_CWD;
|
|
175
|
+
if (!projectMap.has(key)) projectMap.set(key, []);
|
|
176
|
+
projectMap.get(key).push(job);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build per-project candidate list (only projects that have pending jobs).
|
|
180
|
+
const projectCandidates = [];
|
|
181
|
+
for (const [, projectJobs] of projectMap) {
|
|
182
|
+
const hasPending = projectJobs.some(
|
|
183
|
+
(j) => j.status === 'pending' && !running.has(j.slug),
|
|
184
|
+
);
|
|
185
|
+
if (!hasPending) continue;
|
|
186
|
+
|
|
187
|
+
const runningSlugsInProject = new Set(
|
|
188
|
+
projectJobs.filter((j) => running.has(j.slug)).map((j) => j.slug),
|
|
189
|
+
);
|
|
190
|
+
const lowestPendingForProject = projectJobs
|
|
191
|
+
.filter((j) => j.status === 'pending' && !running.has(j.slug))
|
|
192
|
+
.reduce((min, j) => Math.min(min, j.parallelGroup ?? 99), Infinity);
|
|
193
|
+
|
|
194
|
+
projectCandidates.push({ projectJobs, runningSlugsInProject, lowestPendingForProject });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sort by lowest pending group so earlier (higher-priority) groups win
|
|
198
|
+
// slot allocation ties across projects.
|
|
199
|
+
projectCandidates.sort((a, b) => a.lowestPendingForProject - b.lowestPendingForProject);
|
|
200
|
+
|
|
201
|
+
// Aggregate batch across projects, consuming global slots as we go.
|
|
202
|
+
const batch = [];
|
|
203
|
+
for (const { projectJobs, runningSlugsInProject } of projectCandidates) {
|
|
204
|
+
if (slots <= 0) break;
|
|
205
|
+
const projectBatch = pickForProject(projectJobs, runningSlugsInProject, slots);
|
|
206
|
+
batch.push(...projectBatch);
|
|
207
|
+
slots -= projectBatch.length;
|
|
208
|
+
}
|
|
209
|
+
return batch;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD };
|