claude-code-session-manager 0.120.0 → 0.122.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/{AgentLibrary-LBg3mVL8.js → AgentLibrary-BmJ8hIPw.js} +1 -1
- package/dist/assets/{Button-C4BN5ISR.js → Button-BS4kmz4p.js} +1 -1
- package/dist/assets/{DataModel-CHhjk61s.js → DataModel-DZHBZrfL.js} +1 -1
- package/dist/assets/History-pg00b2Gd.js +2 -0
- package/dist/assets/{Hooks-ByWVgXIf.js → Hooks-BTEs0UZY.js} +1 -1
- package/dist/assets/{Library-QvYVAEmY.js → Library-DuaJ-VSn.js} +1 -1
- package/dist/assets/{ListDetail-FvNe6fo5.js → ListDetail-Cud72s-O.js} +1 -1
- package/dist/assets/{McpServers-C7EmPm8B.js → McpServers-Dy-wnG_X.js} +1 -1
- package/dist/assets/{Memory-nyLR4Yw4.js → Memory-Bs1T1Jo7.js} +1 -1
- package/dist/assets/{Panel-thElCbLX.js → Panel-8UhLXEOW.js} +1 -1
- package/dist/assets/{Permissions-FQ9GM6Ca.js → Permissions-BfwzvmBS.js} +1 -1
- package/dist/assets/Plugins-McjyvvKh.js +2 -0
- package/dist/assets/{ProvenanceBadge-lAViG8yV.js → ProvenanceBadge-D0Ov_6WJ.js} +1 -1
- package/dist/assets/{RemoteMachines-AWpARk7N.js → RemoteMachines-gOlFTvWq.js} +1 -1
- package/dist/assets/{SaveBar-c15EEGB_.js → SaveBar-Cd6hJR6K.js} +1 -1
- package/dist/assets/Scheduler-BZXKqYJP.js +16 -0
- package/dist/assets/{ScopeSwitcher-BLep1rtB.js → ScopeSwitcher-C24ysV1K.js} +1 -1
- package/dist/assets/{Settings-D1kTUIDB.js → Settings-DWnN2qxn.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-_40_f0t3.js → SkillReferenceGraph-BsWuiTbw.js} +1 -1
- package/dist/assets/{Skills-DaTkSOuU.js → Skills-6pHn6glu.js} +1 -1
- package/dist/assets/{SystemPrompt-DxNf-soM.js → SystemPrompt-DT0LBH-K.js} +1 -1
- package/dist/assets/{TagLibrary-L6KpBV-x.js → TagLibrary-DbJ0zbYL.js} +1 -1
- package/dist/assets/{TiptapBody-C_XjtJ82.js → TiptapBody-C4kIvIMH.js} +1 -1
- package/dist/assets/{Toggle-H0rERKNI.js → Toggle-pP19zlyk.js} +1 -1
- package/dist/assets/{ViewTabs-ZUWphDat.js → ViewTabs-CpzN8e5I.js} +1 -1
- package/dist/assets/{historyTotals-BxFKOztb.js → historyTotals-Caq78bb5.js} +1 -1
- package/dist/assets/{index-B5wj0DEX.js → index-Bjtguv6I.js} +434 -434
- package/dist/assets/{settingsSchema-BRcYkXJN.js → settingsSchema-CC2ySu3d.js} +1 -1
- package/dist/assets/{skillFrontmatter-DFGp4DST.js → skillFrontmatter-hDhNo8jE.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-VvrgalfC.js → useAgentsDirWatch-BuOtY25U.js} +1 -1
- package/dist/assets/{useRemotePeers-mvXKNEE3.js → useRemotePeers-CNQmRBpt.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -7
- package/plugins/session-manager-dev/skills/develop/standards.md +4 -10
- package/scripts/scheduler-mcp-server.cjs +2 -2
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +56 -43
- package/src/main/docEdit.cjs +1 -0
- package/src/main/index.cjs +12 -13
- package/src/main/ipcSchemas.cjs +17 -3
- package/src/main/lib/classifyPromptTicket.cjs +1 -0
- package/src/main/lib/claude/effectiveModelInfo.cjs +9 -21
- package/src/main/lib/claude/personaToolScope.cjs +7 -1
- package/src/main/lib/claude/runClaudeP.cjs +1 -0
- package/src/main/lib/epic/delegationReadiness.cjs +75 -8
- package/src/main/lib/mcpToolCatalog.cjs +0 -1
- package/src/main/lib/persona-macro/macroLibrary.cjs +75 -0
- package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +11 -0
- package/src/main/lib/prd/prdCreate.cjs +28 -1
- package/src/main/lib/prd/prdSizing.cjs +1 -8
- package/src/main/lib/remoteTranscriptTail.cjs +69 -2
- package/src/main/lib/runSeeders.cjs +21 -0
- package/src/main/lib/scheduler/loopEfficiency.cjs +109 -0
- package/src/main/lib/scheduler/queueHistory.cjs +44 -1
- package/src/main/lib/scheduler/queueStore.cjs +4 -72
- package/src/main/lib/scheduler/rcaReport.cjs +1 -1
- package/src/main/lib/scheduler/refireCap.cjs +40 -0
- package/src/main/lib/scheduler/reviewNotice.cjs +1 -1
- package/src/main/lib/scheduler/schedulerConfig.cjs +1 -2
- package/src/main/lib/scheduler/schedulerPaths.cjs +0 -2
- package/src/main/lib/scheduler/sessionSlots.cjs +1 -1
- package/src/main/lib/telemetry/auditLog.cjs +65 -14
- package/src/main/loopEfficiencyHandlers.cjs +160 -0
- package/src/main/queueOps.cjs +4 -9
- package/src/main/runVerify.cjs +22 -454
- package/src/main/scheduler/reviewLadder.cjs +12 -2
- package/src/main/scheduler.cjs +103 -177
- package/src/main/seedDevPlugin.cjs +49 -22
- package/src/main/seedStatus.cjs +3 -2
- package/src/main/supervisor.cjs +1 -0
- package/src/main/templates/PRD_AUTHORING.md +16 -31
- package/src/main/webRemote.cjs +82 -91
- package/src/preload/api.d.ts +39 -6
- package/src/preload/index.cjs +3 -0
- package/src/seed/agents/dev-lead.md +2 -9
- package/dist/assets/History-CL-T6Lv3.js +0 -2
- package/dist/assets/Plugins-D9KE7XTI.js +0 -2
- package/dist/assets/Scheduler-FvduP1FG.js +0 -16
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// disk. appendHistory/readHistory own the actual fs I/O.
|
|
12
12
|
|
|
13
13
|
const path = require('path');
|
|
14
|
-
const
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const fsp = fs.promises;
|
|
15
16
|
const { HISTORY_RETENTION_MS } = require('./schedulerConfig.cjs');
|
|
16
17
|
const { projectHistoryPath, stateCwds } = require('./queueStore.cjs');
|
|
17
18
|
const { queueHistoryPath } = require('./schedulerPaths.cjs');
|
|
@@ -288,6 +289,47 @@ async function readHistoryFile(target, cap) {
|
|
|
288
289
|
}
|
|
289
290
|
}
|
|
290
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Sync bounded tail read of one history file: newest-first parsed rows, at
|
|
294
|
+
* most `maxRows` (clamped [1,500]) drawn from at most the last `maxBytes`
|
|
295
|
+
* bytes (default 1 MB). Missing/unreadable file -> []. Never throws.
|
|
296
|
+
*/
|
|
297
|
+
function readHistoryFileTailSync(target, { maxRows = 500, maxBytes = 1024 * 1024 } = {}) {
|
|
298
|
+
const cap = Math.max(1, Math.min(500, Math.floor(maxRows) || 500));
|
|
299
|
+
let fd;
|
|
300
|
+
try {
|
|
301
|
+
fd = fs.openSync(target, 'r');
|
|
302
|
+
const { size } = fs.fstatSync(fd);
|
|
303
|
+
if (size === 0) return [];
|
|
304
|
+
const length = Math.min(Math.max(1, maxBytes), size);
|
|
305
|
+
const start = size - length;
|
|
306
|
+
const buf = Buffer.alloc(length);
|
|
307
|
+
fs.readSync(fd, buf, 0, length, start);
|
|
308
|
+
let text = buf.toString('utf8');
|
|
309
|
+
if (start > 0) {
|
|
310
|
+
// Chunk may begin mid-line; drop the partial first fragment.
|
|
311
|
+
const firstNl = text.indexOf('\n');
|
|
312
|
+
text = firstNl === -1 ? '' : text.slice(firstNl + 1);
|
|
313
|
+
}
|
|
314
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean).slice(-cap);
|
|
315
|
+
const parsed = [];
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
try {
|
|
318
|
+
parsed.push(JSON.parse(line));
|
|
319
|
+
} catch {
|
|
320
|
+
// corrupt/partial line — skip
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return parsed.reverse();
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
} finally {
|
|
327
|
+
if (fd !== undefined) {
|
|
328
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
291
333
|
// mtime-gated cache, same convention as scheduler/prdParser.cjs's dirCache —
|
|
292
334
|
// repeated reconcile() calls between appends hit the cache instead of
|
|
293
335
|
// re-reading/re-parsing a JSONL file that only grows over time.
|
|
@@ -467,6 +509,7 @@ module.exports = {
|
|
|
467
509
|
appendHistory,
|
|
468
510
|
slimHistoryRow,
|
|
469
511
|
readHistory,
|
|
512
|
+
readHistoryFileTailSync,
|
|
470
513
|
historyTerminalBySlug,
|
|
471
514
|
completedSlugsForCwd,
|
|
472
515
|
};
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - Per-project history: `<cwd>/session-manager-operations/scheduler/state/history.jsonl`
|
|
12
12
|
* (owned by queueHistory.cjs, path resolved here)
|
|
13
13
|
* - Machine runtime state: `~/.claude/session-manager/scheduler-machine.json`
|
|
14
|
-
* (config, paused/rate-limit,
|
|
14
|
+
* (config, paused/rate-limit, lastRunAt — these are
|
|
15
15
|
* Session-Manager runtime concerns, like the sessionSlots pool, not any
|
|
16
16
|
* one project's data. Run logs under scheduled-plans/runs/ stay
|
|
17
17
|
* machine-local for the same reason: they're execution artifacts of this
|
|
@@ -230,7 +230,6 @@ function bustCwdCache() {
|
|
|
230
230
|
function shapeMachine(data) {
|
|
231
231
|
return {
|
|
232
232
|
config: data.config || {},
|
|
233
|
-
scheduledFor: data.scheduledFor ?? null,
|
|
234
233
|
lastRunAt: data.lastRunAt ?? null,
|
|
235
234
|
// Distinct from lastRunAt (stamped only when tickQueue actually launches a
|
|
236
235
|
// job): this is stamped every time tickQueue gets far enough to evaluate
|
|
@@ -451,7 +450,7 @@ function shapeJobs(raw, file) {
|
|
|
451
450
|
}
|
|
452
451
|
|
|
453
452
|
/**
|
|
454
|
-
* readMergedSync(opts?) → { config, jobs,
|
|
453
|
+
* readMergedSync(opts?) → { config, jobs, lastRunAt, paused,
|
|
455
454
|
* unreadable?, unreadablePath?, unreadableCwds, sourceCwds }.
|
|
456
455
|
*
|
|
457
456
|
* `unreadable` is reserved for the machine-runtime file alone (a machine-wide
|
|
@@ -462,7 +461,7 @@ function shapeJobs(raw, file) {
|
|
|
462
461
|
* writeSplit can persist "this project now has zero jobs".
|
|
463
462
|
*/
|
|
464
463
|
function readMergedSync(opts) {
|
|
465
|
-
const out = { config: {}, jobs: [],
|
|
464
|
+
const out = { config: {}, jobs: [], lastRunAt: null, lastDispatchAttemptAt: null, paused: null, drain: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [], unreadableCwds: [] };
|
|
466
465
|
const sourceCwds = [];
|
|
467
466
|
const machine = loadMachineStateSync();
|
|
468
467
|
if (machine.shaped) {
|
|
@@ -496,7 +495,7 @@ function readMergedSync(opts) {
|
|
|
496
495
|
|
|
497
496
|
/** Async twin of readMergedSync for IPC hot paths. */
|
|
498
497
|
async function readMerged(opts) {
|
|
499
|
-
const out = { config: {}, jobs: [],
|
|
498
|
+
const out = { config: {}, jobs: [], lastRunAt: null, lastDispatchAttemptAt: null, paused: null, drain: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [], unreadableCwds: [] };
|
|
500
499
|
const sourceCwds = [];
|
|
501
500
|
const machine = await loadMachineState();
|
|
502
501
|
if (machine.shaped) {
|
|
@@ -547,7 +546,6 @@ function defineSources(state, sourceCwds) {
|
|
|
547
546
|
async function writeSplit(state, defaultCwd) {
|
|
548
547
|
await writeJsonAtomic(schedulerPaths.machineStatePath(), {
|
|
549
548
|
config: state.config,
|
|
550
|
-
scheduledFor: state.scheduledFor ?? null,
|
|
551
549
|
lastRunAt: state.lastRunAt ?? null,
|
|
552
550
|
lastDispatchAttemptAt: state.lastDispatchAttemptAt ?? null,
|
|
553
551
|
paused: state.paused ?? null,
|
|
@@ -581,70 +579,6 @@ async function writeSplit(state, defaultCwd) {
|
|
|
581
579
|
bustCwdCache();
|
|
582
580
|
}
|
|
583
581
|
|
|
584
|
-
// ---------- legacy migration ----------
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* migrateLegacyGlobalQueue(defaultCwd) — one-time boot split of the retired
|
|
588
|
-
* global queue.json into per-project shards. Shard rows win over legacy rows
|
|
589
|
-
* with the same slug (the shard is newer by construction). The legacy file is
|
|
590
|
-
* renamed to `queue.json.retired-<epoch>` so a rollback can recover it but no
|
|
591
|
-
* reader ever consults it again. Machine fields (config/paused/...) migrate
|
|
592
|
-
* only when no machine file exists yet. Idempotent: no legacy file → no-op.
|
|
593
|
-
*/
|
|
594
|
-
async function migrateLegacyGlobalQueue(defaultCwd) {
|
|
595
|
-
let raw;
|
|
596
|
-
try {
|
|
597
|
-
raw = await fsp.readFile(schedulerPaths.legacyQueuePath(), 'utf8');
|
|
598
|
-
} catch {
|
|
599
|
-
return { migrated: false };
|
|
600
|
-
}
|
|
601
|
-
let legacy;
|
|
602
|
-
try {
|
|
603
|
-
legacy = JSON.parse(raw);
|
|
604
|
-
} catch (e) {
|
|
605
|
-
console.error(`[queueStore] legacy queue.json unparseable — leaving in place: ${e?.message}`);
|
|
606
|
-
return { migrated: false, error: e?.message };
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
if (!fs.existsSync(schedulerPaths.machineStatePath())) {
|
|
610
|
-
await writeJsonAtomic(schedulerPaths.machineStatePath(), {
|
|
611
|
-
config: legacy.config || {},
|
|
612
|
-
scheduledFor: legacy.scheduledFor ?? null,
|
|
613
|
-
lastRunAt: legacy.lastRunAt ?? null,
|
|
614
|
-
paused: legacy.paused ?? null,
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const legacyJobs = Array.isArray(legacy.jobs) ? legacy.jobs : [];
|
|
619
|
-
const byCwd = new Map();
|
|
620
|
-
for (const job of legacyJobs) {
|
|
621
|
-
const cwd = job.cwd || defaultCwd;
|
|
622
|
-
if (!cwd) continue;
|
|
623
|
-
if (!byCwd.has(cwd)) byCwd.set(cwd, []);
|
|
624
|
-
byCwd.get(cwd).push(job);
|
|
625
|
-
}
|
|
626
|
-
let moved = 0;
|
|
627
|
-
for (const [cwd, jobs] of byCwd) {
|
|
628
|
-
const file = queuePathOrSkip(cwd, 'legacy split');
|
|
629
|
-
if (!file) continue;
|
|
630
|
-
let existing = [];
|
|
631
|
-
try { existing = shapeJobs(await fsp.readFile(file, 'utf8'), file).jobs; } catch { /* fresh shard */ }
|
|
632
|
-
const have = new Set(existing.map((j) => j.slug));
|
|
633
|
-
const merged = [...existing, ...jobs.filter((j) => !have.has(j.slug))];
|
|
634
|
-
try {
|
|
635
|
-
await writeJsonAtomic(file, { jobs: merged });
|
|
636
|
-
moved += merged.length - existing.length;
|
|
637
|
-
} catch (e) {
|
|
638
|
-
console.error(`[queueStore] legacy split: failed to write ${file}: ${e?.message}`);
|
|
639
|
-
return { migrated: false, error: e?.message };
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
await fsp.rename(schedulerPaths.legacyQueuePath(), `${schedulerPaths.legacyQueuePath()}.retired-${Date.now()}`);
|
|
644
|
-
bustCwdCache();
|
|
645
|
-
return { migrated: true, moved, projects: byCwd.size };
|
|
646
|
-
}
|
|
647
|
-
|
|
648
582
|
module.exports = {
|
|
649
583
|
STATE_SUBPATH,
|
|
650
584
|
projectStateDir,
|
|
@@ -656,7 +590,6 @@ module.exports = {
|
|
|
656
590
|
readMerged,
|
|
657
591
|
readMergedSync,
|
|
658
592
|
writeSplit,
|
|
659
|
-
migrateLegacyGlobalQueue,
|
|
660
593
|
writeJsonAtomic,
|
|
661
594
|
writeJsonAtomicSync,
|
|
662
595
|
findLongestValidJsonPrefix,
|
|
@@ -664,4 +597,3 @@ module.exports = {
|
|
|
664
597
|
|
|
665
598
|
// Lazy path getters (SM_SCHEDULER_HOME resolved at read, never at require).
|
|
666
599
|
Object.defineProperty(module.exports, 'MACHINE_STATE_PATH', { get: schedulerPaths.machineStatePath, enumerable: true });
|
|
667
|
-
Object.defineProperty(module.exports, 'LEGACY_QUEUE_PATH', { get: schedulerPaths.legacyQueuePath, enumerable: true });
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* It used to file a `proposed` Epic per parked job; Epics are now created in
|
|
13
13
|
* exactly one place — the human pressing New Epic (see epicMint.cjs's
|
|
14
14
|
* SINGLE-CREATOR LAW). Surfacing the parked job back to a human is the
|
|
15
|
-
* scheduler's job (
|
|
15
|
+
* scheduler's job (flushDueReviewNotices → appendResponseEventIfKnown), which
|
|
16
16
|
* routes it into the Epic that AUTHORED the PRD rather than opening a
|
|
17
17
|
* sibling Epic about it.
|
|
18
18
|
*
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* refireCap.cjs — pure decision for a job the post-run verifier downgraded
|
|
3
|
+
* back to `pending` (verdict `halt` / `deps_unmet`).
|
|
4
|
+
*
|
|
5
|
+
* Why: scheduler.cjs re-fired such jobs with no counter, so any verifier
|
|
6
|
+
* disagreement re-ran a billed `claude -p` forever (Bilko-Launch-Kit,
|
|
7
|
+
* 2026-10-11: 125 runs, 846 turns, $28 in 3.5 hours). This helper decides,
|
|
8
|
+
* from the job's consecutive-downgrade count, whether to re-fire or park.
|
|
9
|
+
*
|
|
10
|
+
* Pure: no I/O, no mutation of inputs. Complexity O(1).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
/** Max consecutive post-run downgrades that are still re-fired. */
|
|
16
|
+
const MAX_CONSECUTIVE_VERIFY_REFIRES = 2;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ verifyRefireCount?: unknown }} job
|
|
20
|
+
* @param {{ verdict?: string, reason?: string }} verifyResult
|
|
21
|
+
* @param {{ cap?: number }} [opts]
|
|
22
|
+
* @returns {{ action: 'refire', nextCount: number }
|
|
23
|
+
* | { action: 'park', nextCount: number, verdict: 'refire_capped', reason: string }}
|
|
24
|
+
*/
|
|
25
|
+
function decideVerifyDowngrade(job, verifyResult, { cap = MAX_CONSECUTIVE_VERIFY_REFIRES } = {}) {
|
|
26
|
+
const raw = job && job.verifyRefireCount;
|
|
27
|
+
const count = Number.isInteger(raw) && raw > 0 ? raw : 0;
|
|
28
|
+
const nextCount = count + 1;
|
|
29
|
+
const effectiveCap = Math.max(0, cap);
|
|
30
|
+
if (nextCount <= effectiveCap) return { action: 'refire', nextCount };
|
|
31
|
+
const vr = verifyResult || {};
|
|
32
|
+
return {
|
|
33
|
+
action: 'park',
|
|
34
|
+
nextCount,
|
|
35
|
+
verdict: 'refire_capped',
|
|
36
|
+
reason: `re-fire cap: ${nextCount} consecutive post-run downgrades (${vr.verdict}: ${vr.reason})`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { MAX_CONSECUTIVE_VERIFY_REFIRES, decideVerifyDowngrade };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* notice (this PRD).
|
|
6
6
|
*
|
|
7
7
|
* Before this module, a park into `needs_review` told the authoring Epic
|
|
8
|
-
* immediately
|
|
8
|
+
* immediately at park time, BEFORE the scheduler's own
|
|
9
9
|
* recovery ladder (mechanical re-integration, resume, quarantine, auto-fix,
|
|
10
10
|
* gate re-run, bounded auto-resolve) ever got a chance to run. Most parks
|
|
11
11
|
* heal on their own, so that immediate message was usually a false alarm.
|
|
@@ -78,8 +78,7 @@ module.exports = {
|
|
|
78
78
|
// this age is escalated: warn-logged naming project + slug + age, and
|
|
79
79
|
// surfaced distinctly on Home (see homeNeedsYou.ts's matching constant)
|
|
80
80
|
// so it cannot be stranded indefinitely with nothing looking at it — see
|
|
81
|
-
// findStaleQuarantinedJobs in scheduler.cjs.
|
|
82
|
-
// SM_QUARANTINE_ESCALATE_HOURS for testing/tuning.
|
|
81
|
+
// findStaleQuarantinedJobs in scheduler.cjs.
|
|
83
82
|
QUARANTINE_ESCALATE_MS: 24 * 60 * 60_000,
|
|
84
83
|
|
|
85
84
|
// A project with pending work, nothing of its own running, and an oldest
|
|
@@ -104,7 +104,6 @@ function scheduledPlansRoot() { return assertNotLiveRoot(path.join(schedulerHome
|
|
|
104
104
|
function runsDir() { return assertNotLiveRoot(path.join(scheduledPlansRoot(), 'runs'), 'runsDir'); }
|
|
105
105
|
function prdsRoot() { return assertNotLiveRoot(path.join(scheduledPlansRoot(), 'prds'), 'prdsRoot'); }
|
|
106
106
|
function machineStatePath() { return assertNotLiveRoot(path.join(schedulerHome(), 'scheduler-machine.json'), 'machineStatePath'); }
|
|
107
|
-
function legacyQueuePath() { return assertNotLiveRoot(path.join(scheduledPlansRoot(), 'queue.json'), 'legacyQueuePath'); }
|
|
108
107
|
function schedulerStatePath() { return assertNotLiveRoot(path.join(schedulerHome(), 'scheduler-state.json'), 'schedulerStatePath'); }
|
|
109
108
|
function heartbeatPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'scheduler-heartbeat.log'), 'heartbeatPath'); }
|
|
110
109
|
function sessionSlotsConfigPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'session-slots-config.json'), 'sessionSlotsConfigPath'); }
|
|
@@ -193,7 +192,6 @@ module.exports = {
|
|
|
193
192
|
runsDir,
|
|
194
193
|
prdsRoot,
|
|
195
194
|
machineStatePath,
|
|
196
|
-
legacyQueuePath,
|
|
197
195
|
schedulerStatePath,
|
|
198
196
|
heartbeatPath,
|
|
199
197
|
sessionSlotsConfigPath,
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* state a project's single-writer law needs to arbitrate, and it must
|
|
8
8
|
* survive even if the project's ops root doesn't exist yet.
|
|
9
9
|
*
|
|
10
|
-
* Rotation: on the first append in a new calendar month (UTC) the live file
|
|
11
|
-
* gzipped to audit-log-<YYYY-MM>.jsonl.gz beside it (YYYY-MM = the month of the
|
|
10
|
+
* Rotation: on the first append in a new calendar month (UTC), or once the live file passes
|
|
11
|
+
* 32 MB, the live file is renamed synchronously and then stream-gzipped (async) to audit-log-<YYYY-MM>.jsonl.gz beside it (YYYY-MM = the month of the
|
|
12
12
|
* file's last write) and a fresh live file starts; the newest `auditLogMonths` rolled
|
|
13
13
|
* files are kept. The live file stays plain JSONL, so readers are unaffected.
|
|
14
14
|
*
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
const fs = require('node:fs');
|
|
24
24
|
const path = require('node:path');
|
|
25
25
|
const zlib = require('node:zlib');
|
|
26
|
+
const { pipeline } = require('node:stream/promises');
|
|
26
27
|
|
|
27
28
|
const { auditLogPath } = require('../scheduler/schedulerPaths.cjs');
|
|
28
29
|
|
|
@@ -55,23 +56,73 @@ const ROLLED_RE = /^audit-log-\d{4}-\d{2}\.jsonl\.gz$/;
|
|
|
55
56
|
|
|
56
57
|
function monthKey(d) { return d.toISOString().slice(0, 7); }
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
const MAX_LIVE_BYTES = 32 * 1024 * 1024;
|
|
60
|
+
let rollSeq = 0;
|
|
61
|
+
const inflight = new Set(); // renamed files currently being compressed
|
|
62
|
+
let chain = Promise.resolve();
|
|
63
|
+
const settling = new Set(); // promises of in-progress compressions
|
|
64
|
+
|
|
65
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
66
|
+
|
|
67
|
+
function pruneRolled(dir) {
|
|
68
|
+
const rolls = fs.readdirSync(dir).filter((f) => ROLLED_RE.test(f)).sort();
|
|
69
|
+
for (const f of rolls.slice(0, Math.max(0, rolls.length - maxRolled()))) {
|
|
70
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch { /* best-effort */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stream-gzip `pending` onto the month archive (append: members concatenate), then unlink it. */
|
|
75
|
+
async function compressPending(pending, rolled, dir) {
|
|
76
|
+
let before = 0;
|
|
77
|
+
try { before = fs.statSync(rolled).size; } catch { /* new archive */ }
|
|
78
|
+
try {
|
|
79
|
+
await pipeline(fs.createReadStream(pending), zlib.createGzip(), fs.createWriteStream(rolled, { flags: 'a' }));
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Drop any partial gzip member so the archive stays valid; the pending file is kept for retry.
|
|
82
|
+
try { if (fs.existsSync(rolled)) fs.truncateSync(rolled, before); } catch { /* best-effort */ }
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
fs.unlinkSync(pending);
|
|
86
|
+
try { pruneRolled(dir); } catch { /* best-effort */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function startCompress(pending, rolled, dir) {
|
|
90
|
+
if (inflight.has(pending)) return;
|
|
91
|
+
inflight.add(pending);
|
|
92
|
+
// Serialized: two members appended to one archive concurrently would interleave.
|
|
93
|
+
const p = chain.then(() => compressPending(pending, rolled, dir))
|
|
94
|
+
.catch((e) => { console.error('[auditLog] roll compress failed; will retry', e?.message ?? String(e)); })
|
|
95
|
+
.finally(() => { inflight.delete(pending); settling.delete(p); });
|
|
96
|
+
chain = p;
|
|
97
|
+
settling.add(p);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolves once every in-flight roll compression has finished (success or failure). Test seam. */
|
|
101
|
+
async function settleRolls() {
|
|
102
|
+
while (settling.size) await Promise.all([...settling]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Roll the live file if its last write was in an earlier month than `now`, or it
|
|
107
|
+
* exceeds `maxBytes`. The rename is synchronous; compression is async. Leftover
|
|
108
|
+
* renamed files from a failed compress are retried here.
|
|
109
|
+
*/
|
|
110
|
+
function rotateIfNeeded(target, now, maxBytes) {
|
|
60
111
|
let st;
|
|
61
112
|
try { st = fs.statSync(target); } catch { return; }
|
|
62
113
|
if (!st.isFile() || st.size === 0) return;
|
|
63
114
|
const oldMonth = monthKey(st.mtime);
|
|
64
|
-
if (oldMonth === monthKey(now)) return;
|
|
115
|
+
if (oldMonth === monthKey(now) && st.size <= maxBytes) return;
|
|
65
116
|
const dir = path.dirname(target);
|
|
66
117
|
const base = path.basename(target, path.extname(target));
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
fs.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
try { fs.unlinkSync(path.join(dir, f)); } catch { /* best-effort */ }
|
|
118
|
+
const pending = path.join(dir, `${base}-${oldMonth}.jsonl.rolling-${Date.now().toString(36)}${(rollSeq++).toString(36)}`);
|
|
119
|
+
fs.renameSync(target, pending);
|
|
120
|
+
const pendingRe = new RegExp(`^${escapeRe(base)}-(\\d{4}-\\d{2})\\.jsonl\\.rolling-[a-z0-9]+$`);
|
|
121
|
+
for (const f of fs.readdirSync(dir)) {
|
|
122
|
+
const m = pendingRe.exec(f);
|
|
123
|
+
if (m) startCompress(path.join(dir, f), path.join(dir, `${base}-${m[1]}.jsonl.gz`), dir);
|
|
74
124
|
}
|
|
125
|
+
try { pruneRolled(dir); } catch { /* best-effort */ }
|
|
75
126
|
}
|
|
76
127
|
|
|
77
128
|
/**
|
|
@@ -92,7 +143,7 @@ function appendAuditEvent(kind, fields = {}, opts = {}) {
|
|
|
92
143
|
const target = opts.target ?? auditLogPath();
|
|
93
144
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
94
145
|
try {
|
|
95
|
-
|
|
146
|
+
rotateIfNeeded(target, now, opts.maxBytes ?? MAX_LIVE_BYTES);
|
|
96
147
|
} catch (e) {
|
|
97
148
|
console.error('[auditLog] rotation failed', e?.message ?? String(e));
|
|
98
149
|
}
|
|
@@ -127,4 +178,4 @@ function readTail(maxBytes, target = auditLogPath()) {
|
|
|
127
178
|
}
|
|
128
179
|
}
|
|
129
180
|
|
|
130
|
-
module.exports = { appendAuditEvent, auditLogPath, readTail };
|
|
181
|
+
module.exports = { appendAuditEvent, settleRolls, MAX_LIVE_BYTES, auditLogPath, readTail };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loopEfficiencyHandlers.cjs — `history:loop-efficiency` IPC. Gathers this
|
|
3
|
+
* usage week's run records + validator verdicts from the runs dir and feeds
|
|
4
|
+
* the pure calculator in lib/scheduler/loopEfficiency.cjs.
|
|
5
|
+
*
|
|
6
|
+
* Complexity O(runs in window); run logs are immutable after finalize, so
|
|
7
|
+
* validator verdicts are memoized per log path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fsp = require('fs/promises');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { ipcMain } = require('electron');
|
|
15
|
+
const { computeLoopEfficiency } = require('./lib/scheduler/loopEfficiency.cjs');
|
|
16
|
+
const { parseValidationSentinels } = require('./lib/scheduler/validationSentinels.cjs');
|
|
17
|
+
|
|
18
|
+
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
|
19
|
+
const LOG_TAIL_BYTES = 1024 * 1024;
|
|
20
|
+
const USAGE_TIMEOUT_MS = 5000;
|
|
21
|
+
const VALIDATOR_SLUG_RE = /(^|-)validate-/;
|
|
22
|
+
|
|
23
|
+
/** @type {Map<string, Array<{ slug: string, verdict: string, reason: string }>>} */
|
|
24
|
+
const validationMemo = new Map();
|
|
25
|
+
|
|
26
|
+
/** `2026-10-23T01-26-37-026Z` -> epoch ms (NaN if not a run dir name). */
|
|
27
|
+
function runIdToMs(runId) {
|
|
28
|
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(runId);
|
|
29
|
+
return m ? Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`) : NaN;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readJson(p) {
|
|
33
|
+
try { return JSON.parse(await fsp.readFile(p, 'utf8')); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Last 1 MB of a file as text ('' on failure). */
|
|
37
|
+
async function readTail(p) {
|
|
38
|
+
let fh;
|
|
39
|
+
try {
|
|
40
|
+
fh = await fsp.open(p, 'r');
|
|
41
|
+
const { size } = await fh.stat();
|
|
42
|
+
const len = Math.min(size, LOG_TAIL_BYTES);
|
|
43
|
+
const buf = Buffer.alloc(len);
|
|
44
|
+
await fh.read(buf, 0, len, size - len);
|
|
45
|
+
return buf.toString('utf8');
|
|
46
|
+
} catch {
|
|
47
|
+
return '';
|
|
48
|
+
} finally {
|
|
49
|
+
if (fh) await fh.close().catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function validationsForLog(logPath) {
|
|
54
|
+
const hit = validationMemo.get(logPath);
|
|
55
|
+
if (hit) return hit;
|
|
56
|
+
let out = [];
|
|
57
|
+
const tail = await readTail(logPath);
|
|
58
|
+
const lines = tail.split('\n');
|
|
59
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
60
|
+
if (!lines[i].includes('"type":"result"')) continue;
|
|
61
|
+
try {
|
|
62
|
+
const obj = JSON.parse(lines[i]);
|
|
63
|
+
if (typeof obj.result === 'string') out = parseValidationSentinels(obj.result);
|
|
64
|
+
} catch { /* torn line — treat as no verdicts */ }
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
validationMemo.set(logPath, out);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Never throws; resolves null on any failure or after 5 s. */
|
|
72
|
+
async function defaultUsage(fetchUsage) {
|
|
73
|
+
let timer;
|
|
74
|
+
try {
|
|
75
|
+
const fetchFn = fetchUsage || require('./usage.cjs').fetchUsage;
|
|
76
|
+
const r = await Promise.race([
|
|
77
|
+
fetchFn(),
|
|
78
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(null), USAGE_TIMEOUT_MS); }),
|
|
79
|
+
]);
|
|
80
|
+
if (!r) return null;
|
|
81
|
+
// 'ok-stale' = last cached payload served while the API fails / rate-limits.
|
|
82
|
+
if (r.kind === 'ok' || r.kind === 'ok-stale') return r.data && r.data.usage ? r.data.usage : null;
|
|
83
|
+
return null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
} finally {
|
|
87
|
+
if (timer) clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {{ runsRoot?: string, nowMs?: number, usage?: any }} [opts]
|
|
93
|
+
* `usage` is the usage payload (`{ seven_day: { utilization, resets_at } }`) or null.
|
|
94
|
+
*/
|
|
95
|
+
async function gatherLoopEfficiency({ runsRoot, nowMs, usage } = {}) {
|
|
96
|
+
const root = runsRoot || require('./lib/scheduler/schedulerPaths.cjs').runsDir();
|
|
97
|
+
const now = typeof nowMs === 'number' ? nowMs : Date.now();
|
|
98
|
+
|
|
99
|
+
let fromMs = now - WEEK_MS;
|
|
100
|
+
let weeklyUtilizationPct = null;
|
|
101
|
+
const sd = usage && usage.seven_day;
|
|
102
|
+
if (sd && sd.resets_at && typeof sd.utilization === 'number') {
|
|
103
|
+
const reset = typeof sd.resets_at === 'number' ? sd.resets_at : Date.parse(sd.resets_at);
|
|
104
|
+
if (Number.isFinite(reset)) {
|
|
105
|
+
fromMs = reset - WEEK_MS;
|
|
106
|
+
weeklyUtilizationPct = sd.utilization;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const runs = [];
|
|
111
|
+
const validations = [];
|
|
112
|
+
let dirs = [];
|
|
113
|
+
try { dirs = await fsp.readdir(root); } catch { /* no runs dir */ }
|
|
114
|
+
|
|
115
|
+
for (const runId of dirs) {
|
|
116
|
+
const t = runIdToMs(runId);
|
|
117
|
+
if (!Number.isFinite(t) || t < fromMs) continue;
|
|
118
|
+
const runDir = path.join(root, runId);
|
|
119
|
+
let files;
|
|
120
|
+
try { files = await fsp.readdir(runDir); } catch { continue; }
|
|
121
|
+
for (const f of files) {
|
|
122
|
+
if (!f.endsWith('.meta.json')) continue;
|
|
123
|
+
const meta = await readJson(path.join(runDir, f));
|
|
124
|
+
if (!meta || typeof meta !== 'object') continue;
|
|
125
|
+
// Slug comes from the filename, never meta content (no path traversal via meta.slug).
|
|
126
|
+
const slug = f.slice(0, -'.meta.json'.length);
|
|
127
|
+
const vd = await readJson(path.join(runDir, `${slug}.verdicts.json`));
|
|
128
|
+
const startedAtMs = typeof meta.startedAt === 'number' ? meta.startedAt : t;
|
|
129
|
+
const cwd = String(meta.cwd == null ? '' : meta.cwd);
|
|
130
|
+
runs.push({
|
|
131
|
+
cwd,
|
|
132
|
+
slug,
|
|
133
|
+
startedAtMs,
|
|
134
|
+
costUsd: meta.totalCostUsd,
|
|
135
|
+
verdict: vd && typeof vd.verdict === 'string' ? vd.verdict : null,
|
|
136
|
+
});
|
|
137
|
+
if (VALIDATOR_SLUG_RE.test(slug)) {
|
|
138
|
+
for (const v of await validationsForLog(path.join(runDir, `${slug}.log`))) {
|
|
139
|
+
validations.push({
|
|
140
|
+
cwd,
|
|
141
|
+
slug: v.slug,
|
|
142
|
+
verdict: v.verdict === 'verified' ? 'VERIFIED' : 'REFUTED',
|
|
143
|
+
atMs: startedAtMs,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return computeLoopEfficiency({ runs, validations, fromMs, toMs: now, weeklyUtilizationPct });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function registerLoopEfficiencyHandlers() {
|
|
154
|
+
ipcMain.handle('history:loop-efficiency', async () => {
|
|
155
|
+
const usage = await defaultUsage();
|
|
156
|
+
return gatherLoopEfficiency({ nowMs: Date.now(), usage });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { gatherLoopEfficiency, registerLoopEfficiencyHandlers, defaultUsage };
|
package/src/main/queueOps.cjs
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* - `^until ` at the start of a logical line → fizzpop-style poll hang
|
|
19
19
|
* - `^while true` (case insensitive) → unbounded while loop
|
|
20
20
|
* - `for .* in $(seq 1 [5-9][0-9][0-9]+)` → unbounded large seq
|
|
21
|
-
* - missing frontmatter title / cwd
|
|
21
|
+
* - missing frontmatter title / cwd (estimateMinutes is optional; if present
|
|
22
|
+
* it must be a positive integer)
|
|
22
23
|
* - cwd doesn't exist on disk (fs.accessSync)
|
|
23
24
|
* - `--no-verify` or `--no-gpg-sign` → hook-skip flags
|
|
24
25
|
*
|
|
@@ -200,14 +201,8 @@ function lintParsed(slug, raw) {
|
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
rule: 'missing-estimate',
|
|
206
|
-
line: 1,
|
|
207
|
-
snippet: 'frontmatter "estimateMinutes" is required',
|
|
208
|
-
severity: 'warn',
|
|
209
|
-
});
|
|
210
|
-
} else {
|
|
204
|
+
// estimateMinutes is optional (budget.cjs floors a missing one); validate only when present.
|
|
205
|
+
if (fm.estimateMinutes !== undefined && String(fm.estimateMinutes).trim()) {
|
|
211
206
|
const v = Number(fm.estimateMinutes);
|
|
212
207
|
if (!Number.isInteger(v) || v <= 0) {
|
|
213
208
|
findings.push({
|