claude-code-session-manager 0.93.0 → 0.94.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-DDGx3qrn.js → DataModel-B0LDnnSL.js} +1 -1
- package/dist/assets/{History-CHBjYZyD.js → History-C685Kytt.js} +2 -2
- package/dist/assets/{Hooks-DG4c-Rzz.js → Hooks-CwLnp0Z_.js} +3 -3
- package/dist/assets/HostBilko-CwEHEKYk.js +1 -0
- package/dist/assets/{Library-CN1h5rAE.js → Library-BU9np05E.js} +25 -25
- package/dist/assets/{MarkdownEditor-DmBxrhdn.js → MarkdownEditor-B74ogK2f.js} +1 -1
- package/dist/assets/McpServers-BLM3ftzP.js +2 -0
- package/dist/assets/{Memory-Dk4uDCjg.js → Memory-DAP1t8bA.js} +6 -6
- package/dist/assets/{Permissions-BovobeZw.js → Permissions-3pGLJmxQ.js} +3 -3
- package/dist/assets/{Plugins-DvOf_B62.js → Plugins-Q1KoLEzn.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Dutz_4OY.js → ProvenanceBadge-BnjBQiV9.js} +1 -1
- package/dist/assets/{SaveBar-CT5u6ZCJ.js → SaveBar-Zq2NfcPf.js} +1 -1
- package/dist/assets/{Scheduler-B7GSnZj5.js → Scheduler-D488ebKm.js} +9 -9
- package/dist/assets/{ScopeSwitcher-BUYDpz2v.js → ScopeSwitcher-Cnojv22D.js} +1 -1
- package/dist/assets/{Settings-DIRmr3tL.js → Settings-D5Lhj7ga.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-CdGr6d3_.js → SkillReferenceGraph-BRDiE6Hv.js} +2 -2
- package/dist/assets/{Skills-BQOheQv4.js → Skills-BQDqp-EN.js} +2 -2
- package/dist/assets/{SystemPrompt-5LsimKbX.js → SystemPrompt-tFr19Od6.js} +1 -1
- package/dist/assets/{TagLibrary-DreLsT3n.js → TagLibrary-BjyrnaRE.js} +1 -1
- package/dist/assets/{TiptapBody-C9kmlBnI.js → TiptapBody-DGzSg3BO.js} +1 -1
- package/dist/assets/{Toggle-BKLCBu9A.js → Toggle-DzoROibf.js} +1 -1
- package/dist/assets/{index-Db_SK9rW.js → index-CCl4tz-u.js} +821 -821
- package/dist/assets/{settingsSchema-B5jBhSUP.js → settingsSchema-BYKVe1WM.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +6 -3
- package/scripts/README.md +3 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
- package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
- package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
- package/src/main/__tests__/runVerify.test.cjs +12 -1
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +45 -10
- package/src/main/git.cjs +9 -2
- package/src/main/historyAggregator.cjs +2 -19
- package/src/main/index.cjs +7 -8
- package/src/main/ipcSchemas.cjs +13 -3
- package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
- package/src/main/lib/agentPersonaSchema.cjs +2 -0
- package/src/main/lib/childWithLog.cjs +92 -51
- package/src/main/lib/delegationReadiness.cjs +3 -1
- package/src/main/lib/epicSpawnPlan.cjs +27 -8
- package/src/main/lib/epicTranscriptPath.cjs +5 -1
- package/src/main/lib/headTailBuffer.cjs +43 -0
- package/src/main/lib/intradayRefresh.cjs +33 -0
- package/src/main/lib/lruCache.cjs +39 -0
- package/src/main/lib/openExternalApp.cjs +27 -9
- package/src/main/lib/opsErrorLog.cjs +22 -0
- package/src/main/lib/promptSessionSchema.cjs +2 -0
- package/src/main/lib/rendererRecovery.cjs +141 -0
- package/src/main/lib/scheduleJobSchema.cjs +3 -0
- package/src/main/runVerify.cjs +3 -1
- package/src/main/scheduler.cjs +360 -257
- package/src/main/transcripts.cjs +54 -11
- package/src/preload/api.d.ts +3 -1
- package/dist/assets/HostBilko-DHXcpV2c.js +0 -1
- package/dist/assets/McpServers-Beb_HD6I.js +0 -2
package/src/main/chatRunner.cjs
CHANGED
|
@@ -56,6 +56,11 @@ const agentModelResolve = require('./lib/agentModelResolve.cjs');
|
|
|
56
56
|
const { resolveEpicEffort, effortArgs } = require('./lib/agentEffortResolve.cjs');
|
|
57
57
|
const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
|
|
58
58
|
const logs = require('./logs.cjs');
|
|
59
|
+
const { createHeadTailBuffer } = require('./lib/headTailBuffer.cjs');
|
|
60
|
+
|
|
61
|
+
// Caps on per-run stream buffers (a run can last hours).
|
|
62
|
+
const STDERR_TAIL_MAX = 64 * 1024;
|
|
63
|
+
const LINE_BUFFER_MAX = 8 * 1024 * 1024;
|
|
59
64
|
|
|
60
65
|
// The only two CLI errors that mean "wrong session flag" (verified against the
|
|
61
66
|
// real CLI). "in use" → the transcript exists, so resume; "no conversation" →
|
|
@@ -317,6 +322,13 @@ const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilen
|
|
|
317
322
|
// Closes the microtask-wide window in which per-tab exclusivity would
|
|
318
323
|
// otherwise not hold. See pump().
|
|
319
324
|
const dispatching = new Set();
|
|
325
|
+
// sessionIds with a live CLI child: added when pump() dispatches a run, dropped
|
|
326
|
+
// in the same .finally() that frees the lane — i.e. only after executeRun has
|
|
327
|
+
// settled from `close`/`error`/spawn failure. A cancelled child still exiting
|
|
328
|
+
// therefore keeps holding it. Two `claude -p` against one sessionId die with
|
|
329
|
+
// "Session ID … is already in use", so pump() refuses to start a second run
|
|
330
|
+
// for a held sessionId (it waits in `waiting` like any other blocked run).
|
|
331
|
+
const liveSessions = new Set();
|
|
320
332
|
let activeCount = 0;
|
|
321
333
|
|
|
322
334
|
// Indirection so tests can stub the spawn without launching claude.
|
|
@@ -332,6 +344,7 @@ function __setExecutor(fn) { executor = fn || executeRun; }
|
|
|
332
344
|
function __resetQueueForTests() {
|
|
333
345
|
inFlight.clear();
|
|
334
346
|
dispatching.clear();
|
|
347
|
+
liveSessions.clear();
|
|
335
348
|
waiting.length = 0;
|
|
336
349
|
activeCount = 0;
|
|
337
350
|
sessionSlots.__resetForTests();
|
|
@@ -380,6 +393,7 @@ function run(opts) {
|
|
|
380
393
|
// visible in `inFlight` — the same window pump() guards against.
|
|
381
394
|
const tabBusy = inFlight.has(opts.tabId)
|
|
382
395
|
|| dispatching.has(opts.tabId)
|
|
396
|
+
|| liveSessions.has(opts.sessionId)
|
|
383
397
|
|| waiting.some((w) => w.tabId === opts.tabId);
|
|
384
398
|
if (tabBusy && opts.silent) {
|
|
385
399
|
// Silent probes stay best-effort and invisible: a probe that collides is
|
|
@@ -402,7 +416,8 @@ function pump() {
|
|
|
402
416
|
// it in `inFlight` (which happens a microtask later) — without it, one
|
|
403
417
|
// synchronous sweep of this loop could start two runs for the same tab and
|
|
404
418
|
// race two --resume processes against a single sessionId.
|
|
405
|
-
const idx = waiting.findIndex((w) => !inFlight.has(w.tabId) && !dispatching.has(w.tabId)
|
|
419
|
+
const idx = waiting.findIndex((w) => !inFlight.has(w.tabId) && !dispatching.has(w.tabId)
|
|
420
|
+
&& !liveSessions.has(w.sessionId));
|
|
406
421
|
// Everything queued is blocked behind its own tab's live run; the settle()
|
|
407
422
|
// of that run re-pumps.
|
|
408
423
|
if (idx === -1) break;
|
|
@@ -415,6 +430,7 @@ function pump() {
|
|
|
415
430
|
const [job] = waiting.splice(idx, 1);
|
|
416
431
|
activeCount += 1;
|
|
417
432
|
dispatching.add(job.tabId);
|
|
433
|
+
liveSessions.add(job.sessionId);
|
|
418
434
|
Promise.resolve()
|
|
419
435
|
.then(() => {
|
|
420
436
|
const donePromise = executor(job);
|
|
@@ -425,6 +441,7 @@ function pump() {
|
|
|
425
441
|
.catch(() => { /* executeRun never rejects; defensive */ })
|
|
426
442
|
.finally(() => {
|
|
427
443
|
dispatching.delete(job.tabId);
|
|
444
|
+
liveSessions.delete(job.sessionId);
|
|
428
445
|
sessionSlots.release(slotToken);
|
|
429
446
|
activeCount -= 1;
|
|
430
447
|
pump();
|
|
@@ -478,6 +495,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
478
495
|
// funnels through here so the exit path can emit a fallback only when the
|
|
479
496
|
// run settled without one.
|
|
480
497
|
let terminalSent = false;
|
|
498
|
+
let lineOverflowLogged = false;
|
|
481
499
|
const emitTerminal = (channel, payload) => {
|
|
482
500
|
if (terminalSent) return;
|
|
483
501
|
terminalSent = true;
|
|
@@ -700,7 +718,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
700
718
|
// Per-attempt state — a retry starts from empty buffers.
|
|
701
719
|
let lineBuffer = '';
|
|
702
720
|
let finalAssistantText = '';
|
|
703
|
-
|
|
721
|
+
const stderrBuffer = createHeadTailBuffer(STDERR_TAIL_MAX);
|
|
722
|
+
|
|
723
|
+
const stderrText = () => stderrBuffer.matchText();
|
|
704
724
|
|
|
705
725
|
const processLine = (line) => {
|
|
706
726
|
if (!line) return;
|
|
@@ -792,10 +812,25 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
792
812
|
processLine(lineBuffer.slice(0, nl).trim());
|
|
793
813
|
lineBuffer = lineBuffer.slice(nl + 1);
|
|
794
814
|
}
|
|
815
|
+
if (lineBuffer.length > LINE_BUFFER_MAX) {
|
|
816
|
+
// Un-terminated line past the cap: drop it rather than grow forever.
|
|
817
|
+
lineBuffer = '';
|
|
818
|
+
if (!lineOverflowLogged) {
|
|
819
|
+
lineOverflowLogged = true;
|
|
820
|
+
opsErrorLog.appendError({
|
|
821
|
+
cwd,
|
|
822
|
+
scope: 'chatRunner',
|
|
823
|
+
tabId,
|
|
824
|
+
tags: silent ? ['silent-probe'] : [],
|
|
825
|
+
message: `stdout line exceeded ${LINE_BUFFER_MAX} bytes without a newline; buffer dropped`,
|
|
826
|
+
meta: { sessionId, silent },
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
795
830
|
});
|
|
796
831
|
|
|
797
832
|
thisChild.stderr.on('data', (chunk) => {
|
|
798
|
-
stderrBuffer
|
|
833
|
+
stderrBuffer.append(chunk.toString('utf8'));
|
|
799
834
|
});
|
|
800
835
|
|
|
801
836
|
thisChild.on('error', (err) => {
|
|
@@ -824,10 +859,10 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
824
859
|
// terminal latch stay at executeRun level, so this is still one turn.
|
|
825
860
|
if (!terminalSent && !killed && code !== 0 && attemptNo === 1
|
|
826
861
|
&& finalAssistantText === '' && recentToolUses.length === 0
|
|
827
|
-
&& !/rate.?limit/i.test(
|
|
862
|
+
&& !/rate.?limit/i.test(stderrText())) {
|
|
828
863
|
const swapTo = resumeFlag
|
|
829
|
-
? (NO_CONVERSATION_RE.test(
|
|
830
|
-
: (SESSION_IN_USE_RE.test(
|
|
864
|
+
? (NO_CONVERSATION_RE.test(stderrText()) ? false : null)
|
|
865
|
+
: (SESSION_IN_USE_RE.test(stderrText()) ? true : null);
|
|
831
866
|
if (swapTo !== null) {
|
|
832
867
|
if (!silent) {
|
|
833
868
|
broadcast('chat:run:notice', {
|
|
@@ -856,7 +891,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
856
891
|
sessionId,
|
|
857
892
|
message: 'run cancelled',
|
|
858
893
|
});
|
|
859
|
-
} else if (SESSION_IN_USE_RE.test(
|
|
894
|
+
} else if (SESSION_IN_USE_RE.test(stderrText()) || NO_CONVERSATION_RE.test(stderrText())) {
|
|
860
895
|
// A session-flag rejection that survived the one-shot swap (or was
|
|
861
896
|
// not eligible for it). The raw stderr dump names neither the flag,
|
|
862
897
|
// the cwd nor the transcript — a real report cost a full diagnostic
|
|
@@ -875,9 +910,8 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
875
910
|
+ 'the session-flag resolution logic lives in main, and a stale build resolves it with older rules.',
|
|
876
911
|
});
|
|
877
912
|
} else {
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
: '';
|
|
913
|
+
const preview = stderrBuffer.preview();
|
|
914
|
+
const errDetail = preview ? `: ${preview}` : '';
|
|
881
915
|
emitTerminal('chat:run:error', {
|
|
882
916
|
tabId,
|
|
883
917
|
sessionId,
|
|
@@ -996,6 +1030,7 @@ module.exports = {
|
|
|
996
1030
|
run,
|
|
997
1031
|
cancel,
|
|
998
1032
|
__resetQueueForTests,
|
|
1033
|
+
__liveSessionCount: () => liveSessions.size,
|
|
999
1034
|
attachWindow,
|
|
1000
1035
|
registerChatHandlers,
|
|
1001
1036
|
parseStopSignal,
|
package/src/main/git.cjs
CHANGED
|
@@ -23,6 +23,7 @@ const { spawn } = require('node:child_process');
|
|
|
23
23
|
const path = require('node:path');
|
|
24
24
|
const { schemas } = require('./ipcSchemas.cjs');
|
|
25
25
|
const { validatePath } = require('./config.cjs');
|
|
26
|
+
const { LRUCache } = require('./lib/lruCache.cjs');
|
|
26
27
|
|
|
27
28
|
const GIT_TIMEOUT_MS = 5_000;
|
|
28
29
|
const CACHE_TTL_MS = 5_000;
|
|
@@ -30,8 +31,11 @@ const MAX_BUFFER = 10 * 1024 * 1024; // 10 MiB — same cap Unleashed uses
|
|
|
30
31
|
|
|
31
32
|
// Per-cwd in-memory caches. Keyed by the realpath-validated cwd so symlinks
|
|
32
33
|
// to the same repo dedupe. Each entry: { value, expiresAt }.
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// Bounded LRU: worktree cwds are unique per Epic/job, so an unbounded Map grew
|
|
35
|
+
// for the app's lifetime and pinned every files[] array.
|
|
36
|
+
const CACHE_MAX_CWDS = 16;
|
|
37
|
+
const statusCache = new LRUCache(CACHE_MAX_CWDS);
|
|
38
|
+
const fileStatusCache = new LRUCache(CACHE_MAX_CWDS);
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
41
|
* Run `git <args>` in cwd with a hard 5s SIGKILL ceiling. Resolves
|
|
@@ -241,6 +245,7 @@ async function getStatus(cwd) {
|
|
|
241
245
|
const now = Date.now();
|
|
242
246
|
const cached = statusCache.get(realCwd);
|
|
243
247
|
if (cached && cached.expiresAt > now) return cached.value;
|
|
248
|
+
if (cached) statusCache.delete(realCwd);
|
|
244
249
|
|
|
245
250
|
// Single porcelain call gets us branch + ahead/behind + files in one shot.
|
|
246
251
|
const result = await runGit(
|
|
@@ -281,6 +286,7 @@ async function getFileStatus(cwd) {
|
|
|
281
286
|
const now = Date.now();
|
|
282
287
|
const cached = fileStatusCache.get(realCwd);
|
|
283
288
|
if (cached && cached.expiresAt > now) return cached.value;
|
|
289
|
+
if (cached) fileStatusCache.delete(realCwd);
|
|
284
290
|
|
|
285
291
|
const result = await runGit(
|
|
286
292
|
['status', '--porcelain=v1', '-u', '-z'],
|
|
@@ -330,4 +336,5 @@ module.exports = {
|
|
|
330
336
|
parsePorcelain,
|
|
331
337
|
mapStatus,
|
|
332
338
|
clearCache,
|
|
339
|
+
_caches: { statusCache, fileStatusCache, CACHE_MAX_CWDS },
|
|
333
340
|
};
|
|
@@ -49,25 +49,8 @@ function resolvePricingKey(modelId) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// ── LRU cache ─────────────────────────────────────────────────────────────────
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
constructor(max) {
|
|
55
|
-
this._max = max;
|
|
56
|
-
this._m = new Map();
|
|
57
|
-
}
|
|
58
|
-
get(k) {
|
|
59
|
-
if (!this._m.has(k)) return undefined;
|
|
60
|
-
const v = this._m.get(k);
|
|
61
|
-
this._m.delete(k);
|
|
62
|
-
this._m.set(k, v);
|
|
63
|
-
return v;
|
|
64
|
-
}
|
|
65
|
-
set(k, v) {
|
|
66
|
-
this._m.delete(k);
|
|
67
|
-
this._m.set(k, v);
|
|
68
|
-
if (this._m.size > this._max) this._m.delete(this._m.keys().next().value);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
52
|
+
// Shared implementation: lib/lruCache.cjs (re-exported below for existing callers).
|
|
53
|
+
const { LRUCache } = require('./lib/lruCache.cjs');
|
|
71
54
|
|
|
72
55
|
/**
|
|
73
56
|
* Cache for parseJSONL results.
|
package/src/main/index.cjs
CHANGED
|
@@ -82,6 +82,7 @@ const { registerHistoryAggregatorHandlers, finalizeClosedDays, refreshIntradayTo
|
|
|
82
82
|
const runLogRetention = require('./lib/runLogRetention.cjs');
|
|
83
83
|
const { registerHistoryDashboardHandlers } = require('./historyDashboard.cjs');
|
|
84
84
|
const { tryAcquireLock, releaseLock, DEFAULT_LOCK_PATH } = require('./lib/watchdogHelpers.cjs');
|
|
85
|
+
const { createIntradayRefresh } = require('./lib/intradayRefresh.cjs');
|
|
85
86
|
const schedulerConfig = require('./lib/schedulerConfig.cjs');
|
|
86
87
|
const memoryTool = require('./memoryTool.cjs');
|
|
87
88
|
const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
|
|
@@ -204,14 +205,9 @@ const REBOOT_LOG = path.join(os.homedir(), '.claude', 'session-manager-reboot.lo
|
|
|
204
205
|
// finalize pass never interleave writes to the rollup file. A contended lock
|
|
205
206
|
// just means this tick is skipped — the next timer tick (or the next boot)
|
|
206
207
|
// retries, so skipping is always safe.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.catch((e) => {
|
|
211
|
-
logs.writeLine({ scope: 'history-rollup', level: 'error', message: 'refreshIntradayToday failed', meta: { error: e?.message } });
|
|
212
|
-
})
|
|
213
|
-
.finally(() => releaseLock(DEFAULT_LOCK_PATH));
|
|
214
|
-
}
|
|
208
|
+
const runIntradayRefresh = createIntradayRefresh({
|
|
209
|
+
tryAcquireLock, releaseLock, lockPath: DEFAULT_LOCK_PATH, refresh: refreshIntradayToday, logs,
|
|
210
|
+
});
|
|
215
211
|
|
|
216
212
|
function logReboot(line) {
|
|
217
213
|
try {
|
|
@@ -444,6 +440,9 @@ function createWindow() {
|
|
|
444
440
|
});
|
|
445
441
|
});
|
|
446
442
|
|
|
443
|
+
// Bounded auto-recovery for a crashed / hung / failed-to-load renderer.
|
|
444
|
+
require('./lib/rendererRecovery.cjs').attachRendererRecovery(mainWindow.webContents, { logs });
|
|
445
|
+
|
|
447
446
|
// Native right-click menu — Copy / Paste / Select All everywhere. Roles
|
|
448
447
|
// hook into Electron's built-in clipboard/selection plumbing, which xterm.js
|
|
449
448
|
// (and Monaco, and Tiptap) all participate in via the standard DOM
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* Zod schemas for all IPC handler payloads. Applied at the boundary between
|
|
3
4
|
* renderer and main — rejects malformed data before it reaches any business
|
|
@@ -152,20 +153,22 @@ const PROMPT_SESSION_ID_RE = /^(?!__proto__$|constructor$|prototype$)[A-Za-z0-9_
|
|
|
152
153
|
// renderer only logs the failure). Dropping just the offending row matches how
|
|
153
154
|
// the rest of this pipeline already fails: per-id (activeIndexMerge.cjs's
|
|
154
155
|
// tombstone/resurrection handling), never per-batch.
|
|
156
|
+
/** @param {unknown} raw */
|
|
155
157
|
const dropInvalidSessions = (raw) => {
|
|
156
158
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
|
|
159
|
+
const rec = /** @type {Record<string, unknown>} */ (raw);
|
|
157
160
|
const kept = Object.create(null);
|
|
158
|
-
for (const id of Object.keys(
|
|
161
|
+
for (const id of Object.keys(rec)) {
|
|
159
162
|
if (!PROMPT_SESSION_ID_RE.test(id)) {
|
|
160
163
|
console.warn(`[ipc] merge-active-index: dropping session with invalid id ${JSON.stringify(id)}`);
|
|
161
164
|
continue;
|
|
162
165
|
}
|
|
163
|
-
const parsed = PromptSessionSchema.safeParse(
|
|
166
|
+
const parsed = PromptSessionSchema.safeParse(rec[id]);
|
|
164
167
|
if (!parsed.success) {
|
|
165
168
|
console.warn(`[ipc] merge-active-index: dropping malformed session ${id} — ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
166
169
|
continue;
|
|
167
170
|
}
|
|
168
|
-
kept[id] =
|
|
171
|
+
kept[id] = rec[id];
|
|
169
172
|
}
|
|
170
173
|
return kept;
|
|
171
174
|
};
|
|
@@ -1046,6 +1049,13 @@ const telemetrySetConfig = z.object({
|
|
|
1046
1049
|
* call sites already rely on throw semantics for malformed input, so we keep
|
|
1047
1050
|
* that behavior for backwards compatibility.
|
|
1048
1051
|
*/
|
|
1052
|
+
/**
|
|
1053
|
+
* @template T
|
|
1054
|
+
* @template R
|
|
1055
|
+
* @param {{ parse: (payload: unknown) => T }} schema
|
|
1056
|
+
* @param {(parsed: T) => R} handler
|
|
1057
|
+
* @returns {(_event: unknown, payload: unknown) => R}
|
|
1058
|
+
*/
|
|
1049
1059
|
function validated(schema, handler) {
|
|
1050
1060
|
return (_event, payload) => {
|
|
1051
1061
|
const parsed = schema.parse(payload);
|
|
@@ -139,3 +139,183 @@ describe('childWithLog onExit leakedDescendants reporting (PRD 1110)', () => {
|
|
|
139
139
|
15000,
|
|
140
140
|
);
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
// PRD 1353 — async enumeration must never block/alter the kill cascade, and a
|
|
144
|
+
// throwing watchdog must not break the wall-clock kill path.
|
|
145
|
+
describe('childWithLog async enumeration + guarded watchdog (PRD 1353)', () => {
|
|
146
|
+
const { _deps } = require('../childWithLog.cjs');
|
|
147
|
+
const realExecFile = _deps.execFile;
|
|
148
|
+
afterEach(() => { _deps.execFile = realExecFile; });
|
|
149
|
+
|
|
150
|
+
it.skipIf(process.platform !== 'linux')(
|
|
151
|
+
'enumeration failure omits the report, logs, and still kills the group',
|
|
152
|
+
async () => {
|
|
153
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-enumfail-'));
|
|
154
|
+
const logPath = path.join(dir, 'job.log');
|
|
155
|
+
const pidFile = path.join(dir, 'grandchild.pid');
|
|
156
|
+
const { fd, safeLog, closeFd } = openLog(logPath);
|
|
157
|
+
_deps.execFile = (_c, _a, _o, cb) => { setImmediate(() => cb(new Error('ps boom'))); };
|
|
158
|
+
|
|
159
|
+
const exitInfo = await new Promise((resolve) => {
|
|
160
|
+
withChildAndLog({
|
|
161
|
+
fd, logPath, safeLog, closeFd,
|
|
162
|
+
spawn: {
|
|
163
|
+
command: 'sh',
|
|
164
|
+
args: ['-c', `sleep 30 & echo $! > ${pidFile}; exec sleep 0.2`],
|
|
165
|
+
options: { detached: true },
|
|
166
|
+
},
|
|
167
|
+
onExit: (info) => { info.safeLog('onExit-ran\n'); resolve(info); },
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
expect(exitInfo.leakedDescendants).toEqual([]);
|
|
171
|
+
for (let i = 0; i < 20 && !fs.existsSync(pidFile); i++) await new Promise((r) => setTimeout(r, 50));
|
|
172
|
+
const gc = Number(fs.readFileSync(pidFile, 'utf8').trim());
|
|
173
|
+
await new Promise((r) => setTimeout(r, 500)); // SIGTERM lands immediately, no grace wait
|
|
174
|
+
expect(() => process.kill(gc, 0)).toThrow();
|
|
175
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain('enumeration failed');
|
|
176
|
+
},
|
|
177
|
+
15000,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
function runWatchdog(wd) {
|
|
181
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-wd-'));
|
|
182
|
+
const logPath = path.join(dir, 'job.log');
|
|
183
|
+
const { fd, safeLog, closeFd } = openLog(logPath);
|
|
184
|
+
let handle;
|
|
185
|
+
const done = new Promise((resolve) => {
|
|
186
|
+
handle = withChildAndLog({
|
|
187
|
+
fd, logPath, safeLog, closeFd,
|
|
188
|
+
spawn: { command: 'sleep', args: ['5'], options: {} },
|
|
189
|
+
watchdogs: [{ label: 'w', intervalMs: 20, ...wd }],
|
|
190
|
+
onExit: resolve,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
return { logPath, handle, done };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it('a throwing shouldFire does not stop a later tick firing the action', async () => {
|
|
197
|
+
let calls = 0;
|
|
198
|
+
let fired = 0;
|
|
199
|
+
const { handle, logPath, done } = runWatchdog({
|
|
200
|
+
shouldFire: () => { calls++; if (calls < 3) throw new Error('sf boom'); return true; },
|
|
201
|
+
action: (ctx) => { fired++; ctx.killedByWatchdog = 'w'; ctx.killTree('SIGTERM'); },
|
|
202
|
+
});
|
|
203
|
+
const info = await done;
|
|
204
|
+
expect(fired).toBe(1);
|
|
205
|
+
expect(info.killedByWatchdog).toBe('w');
|
|
206
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain('watchdog "w" threw: sf boom');
|
|
207
|
+
handle.cancel();
|
|
208
|
+
}, 10000);
|
|
209
|
+
|
|
210
|
+
it('a throwing action clears its interval and is logged', async () => {
|
|
211
|
+
let should = 0;
|
|
212
|
+
let acts = 0;
|
|
213
|
+
const { handle, logPath, done } = runWatchdog({
|
|
214
|
+
shouldFire: () => { should++; return true; },
|
|
215
|
+
action: () => { acts++; throw new Error('act boom'); },
|
|
216
|
+
});
|
|
217
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
218
|
+
expect(acts).toBe(1);
|
|
219
|
+
expect(should).toBe(1);
|
|
220
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain('watchdog "w" threw: act boom');
|
|
221
|
+
handle.cancel();
|
|
222
|
+
await done;
|
|
223
|
+
}, 10000);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// PRD 1370 — enumerate BEFORE SIGTERM; no-enumeration paths stay synchronous.
|
|
227
|
+
describe('childWithLog sweep ordering + sync paths (PRD 1370)', () => {
|
|
228
|
+
const { _deps } = require('../childWithLog.cjs');
|
|
229
|
+
const realExecFile = _deps.execFile;
|
|
230
|
+
const realKill = process.kill;
|
|
231
|
+
afterEach(() => { _deps.execFile = realExecFile; process.kill = realKill; });
|
|
232
|
+
|
|
233
|
+
function setup(name) {
|
|
234
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), name));
|
|
235
|
+
const logPath = path.join(dir, 'job.log');
|
|
236
|
+
return { logPath, ...openLog(logPath) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
it.skipIf(process.platform !== 'linux').each([
|
|
240
|
+
['successful', (cb) => cb(null, 'PID PGRP %CPU ELAPSED COMMAND\n')],
|
|
241
|
+
['failing', (cb) => cb(new Error('ps boom'))],
|
|
242
|
+
])('SIGTERM waits for the %s enumeration', async (label, respond) => {
|
|
243
|
+
const { logPath, fd, safeLog, closeFd } = setup('sm-child-order-');
|
|
244
|
+
let psCb;
|
|
245
|
+
_deps.execFile = (_c, _a, _o, cb) => { psCb = cb; };
|
|
246
|
+
const groupKills = [];
|
|
247
|
+
process.kill = (pid, sig) => {
|
|
248
|
+
if (pid < -1 && sig === 'SIGTERM') { groupKills.push(pid); return true; }
|
|
249
|
+
return realKill.call(process, pid, sig);
|
|
250
|
+
};
|
|
251
|
+
let info;
|
|
252
|
+
const done = new Promise((resolve) => {
|
|
253
|
+
withChildAndLog({
|
|
254
|
+
fd, logPath, safeLog, closeFd,
|
|
255
|
+
spawn: { command: 'sh', args: ['-c', 'exit 0'], options: { detached: true } },
|
|
256
|
+
onExit: (i) => { info = i; resolve(); },
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
for (let i = 0; i < 100 && !psCb; i++) await new Promise((r) => setTimeout(r, 20));
|
|
260
|
+
expect(psCb).toBeTruthy();
|
|
261
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
262
|
+
expect(groupKills).toEqual([]);
|
|
263
|
+
respond(psCb);
|
|
264
|
+
await done; // onExit runs only after the kill was issued
|
|
265
|
+
expect(groupKills.length).toBe(1);
|
|
266
|
+
expect(info.leakedDescendants).toEqual([]);
|
|
267
|
+
if (label === 'failing') expect(fs.readFileSync(logPath, 'utf8')).toContain('enumeration failed');
|
|
268
|
+
}, 15000);
|
|
269
|
+
|
|
270
|
+
it('runs onExit synchronously when the spawn throws', () => {
|
|
271
|
+
const { logPath, fd, safeLog, closeFd } = setup('sm-child-spawnfail-');
|
|
272
|
+
let ran = false;
|
|
273
|
+
const r = withChildAndLog({
|
|
274
|
+
fd, logPath, safeLog, closeFd,
|
|
275
|
+
spawn: { command: 'sh', args: [], options: { cwd: '/nonexistent\0bad', detached: true } },
|
|
276
|
+
onExit: (i) => { ran = i.spawnFailed === true; },
|
|
277
|
+
});
|
|
278
|
+
expect(r.child).toBeNull();
|
|
279
|
+
expect(ran).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it.skipIf(process.platform !== 'linux')('a throwing deferred onExit is logged and still closes the fd', async () => {
|
|
283
|
+
const { logPath, fd, safeLog, closeFd } = setup('sm-child-throw-');
|
|
284
|
+
_deps.execFile = (_c, _a, _o, cb) => { setImmediate(() => cb(null, 'h\n')); };
|
|
285
|
+
const unhandled = [];
|
|
286
|
+
const h = (e) => unhandled.push(e);
|
|
287
|
+
process.on('unhandledRejection', h);
|
|
288
|
+
let closed = false;
|
|
289
|
+
const wrapClose = () => { closed = true; closeFd(); };
|
|
290
|
+
await new Promise((resolve) => {
|
|
291
|
+
withChildAndLog({
|
|
292
|
+
fd, logPath, safeLog, closeFd: wrapClose,
|
|
293
|
+
spawn: { command: 'sh', args: ['-c', 'exit 0'], options: { detached: true } },
|
|
294
|
+
onExit: () => { setImmediate(resolve); throw new Error('exit boom'); },
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
298
|
+
process.off('unhandledRejection', h);
|
|
299
|
+
expect(unhandled).toEqual([]);
|
|
300
|
+
expect(closed).toBe(true);
|
|
301
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain('onExit threw: exit boom');
|
|
302
|
+
}, 15000);
|
|
303
|
+
|
|
304
|
+
it('a throwing synchronous onExit is logged, closes the fd, and is re-thrown', async () => {
|
|
305
|
+
const { logPath, fd, safeLog, closeFd } = setup('sm-child-throw-sync-');
|
|
306
|
+
let closed = false;
|
|
307
|
+
const wrapClose = () => { closed = true; closeFd(); };
|
|
308
|
+
const thrown = await new Promise((resolve) => {
|
|
309
|
+
const onUncaught = (e) => { process.off('uncaughtException', onUncaught); resolve(e); };
|
|
310
|
+
process.on('uncaughtException', onUncaught);
|
|
311
|
+
withChildAndLog({
|
|
312
|
+
fd, logPath, safeLog, closeFd: wrapClose,
|
|
313
|
+
spawn: { command: 'sh', args: ['-c', 'exit 0'] },
|
|
314
|
+
onExit: () => { throw new Error('exit boom'); },
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
expect(thrown.message).toBe('exit boom');
|
|
318
|
+
expect(closed).toBe(true);
|
|
319
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain('onExit threw: exit boom');
|
|
320
|
+
}, 15000);
|
|
321
|
+
});
|
|
@@ -1154,3 +1154,22 @@ test('ensureGuardsInstalled: records every auto-install attempt in the ops error
|
|
|
1154
1154
|
expect(delegationLines.every((l) => l.level === 'info')).toBe(true);
|
|
1155
1155
|
expect(delegationLines.every((l) => l.meta?.action === 'installed')).toBe(true);
|
|
1156
1156
|
}, 15_000);
|
|
1157
|
+
|
|
1158
|
+
test('ensureGuardsInstalled: an already-installed re-run writes no new ops error-log rows', async () => {
|
|
1159
|
+
const cwd = await mkTmp('sm-ensure-guards-noop-');
|
|
1160
|
+
const homeDir = await mkTmp('sm-ensure-guards-noop-home-');
|
|
1161
|
+
|
|
1162
|
+
await ensureGuardsInstalled(cwd, { homeDir });
|
|
1163
|
+
const logFile = opsErrorLogTodayFile(cwd);
|
|
1164
|
+
const before = fs.readFileSync(logFile, 'utf8');
|
|
1165
|
+
|
|
1166
|
+
clearGuardsInstalledCache();
|
|
1167
|
+
const again = await ensureGuardsInstalled(cwd, { homeDir });
|
|
1168
|
+
expect(Object.values(again.guards).every((g) => g.action === 'already-installed')).toBe(true);
|
|
1169
|
+
expect(fs.readFileSync(logFile, 'utf8')).toBe(before);
|
|
1170
|
+
}, 15_000);
|
|
1171
|
+
|
|
1172
|
+
test('delegationReadiness.cjs source contains no raw NUL byte', () => {
|
|
1173
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'delegationReadiness.cjs'));
|
|
1174
|
+
expect(src.includes(0)).toBe(false);
|
|
1175
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitCacheBound.test.cjs — git status caches are LRU-bounded and drop expired entries.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/gitCacheBound.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { addAllowedRoot } = require('../../config.cjs');
|
|
13
|
+
const git = require('../../git.cjs');
|
|
14
|
+
|
|
15
|
+
const { statusCache, fileStatusCache, CACHE_MAX_CWDS } = git._caches;
|
|
16
|
+
let root;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
git.clearCache();
|
|
19
|
+
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'git-cache-bound-')));
|
|
20
|
+
addAllowedRoot(root);
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function mkdirs(n) {
|
|
28
|
+
return Array.from({ length: n }, (_, i) => {
|
|
29
|
+
const d = path.join(root, `d${i}`);
|
|
30
|
+
fs.mkdirSync(d);
|
|
31
|
+
return fs.realpathSync(d);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('50 distinct cwds never exceed the cap', async () => {
|
|
36
|
+
for (const d of mkdirs(50)) {
|
|
37
|
+
await git.getStatus(d);
|
|
38
|
+
await git.getFileStatus(d);
|
|
39
|
+
}
|
|
40
|
+
expect(statusCache.size).toBeLessThanOrEqual(CACHE_MAX_CWDS);
|
|
41
|
+
expect(fileStatusCache.size).toBeLessThanOrEqual(CACHE_MAX_CWDS);
|
|
42
|
+
expect(statusCache.size).toBe(CACHE_MAX_CWDS);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('an expired entry is deleted when encountered', async () => {
|
|
46
|
+
const [d] = mkdirs(1);
|
|
47
|
+
const t0 = 1_000_000;
|
|
48
|
+
const now = vi.spyOn(Date, 'now').mockReturnValue(t0);
|
|
49
|
+
await git.getStatus(d);
|
|
50
|
+
await git.getFileStatus(d);
|
|
51
|
+
expect(statusCache.get(d).expiresAt).toBe(t0 + 5_000);
|
|
52
|
+
|
|
53
|
+
// Live entry: served from cache, untouched.
|
|
54
|
+
now.mockReturnValue(t0 + 4_999);
|
|
55
|
+
await git.getStatus(d);
|
|
56
|
+
expect(statusCache.get(d).expiresAt).toBe(t0 + 5_000);
|
|
57
|
+
|
|
58
|
+
// Expired: the stale entry is deleted and replaced by a fresh one.
|
|
59
|
+
now.mockReturnValue(t0 + 60_000);
|
|
60
|
+
const del = vi.spyOn(statusCache, 'delete');
|
|
61
|
+
await git.getStatus(d);
|
|
62
|
+
expect(del).toHaveBeenCalledWith(d);
|
|
63
|
+
expect(statusCache.get(d).expiresAt).toBe(t0 + 65_000);
|
|
64
|
+
|
|
65
|
+
const delFile = vi.spyOn(fileStatusCache, 'delete');
|
|
66
|
+
await git.getFileStatus(d);
|
|
67
|
+
expect(delFile).toHaveBeenCalledWith(d);
|
|
68
|
+
expect(fileStatusCache.get(d).expiresAt).toBe(t0 + 65_000);
|
|
69
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* Canonical runtime schema for an Agent persona WRITE payload, mirroring the
|
|
3
4
|
* TS `AgentPersonaSaveInput` interface in src/preload/api.d.ts
|
|
@@ -67,6 +68,7 @@ const AgentPersonaSaveSchema = z.object({
|
|
|
67
68
|
* Throws a clear, descriptive error (not a raw ZodError dump) when `persona`
|
|
68
69
|
* doesn't match AgentPersonaSaveSchema. Mirrors assertValidPromptSession /
|
|
69
70
|
* assertValidScheduleJob's shape and message format exactly.
|
|
71
|
+
* @param {unknown} persona
|
|
70
72
|
*/
|
|
71
73
|
function assertValidAgentPersonaSave(persona) {
|
|
72
74
|
const result = AgentPersonaSaveSchema.safeParse(persona);
|