claude-code-session-manager 0.85.0 → 0.87.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-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +15 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
- package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
- package/src/main/__tests__/machineProfile.test.cjs +19 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/__tests__/telemetryClient.test.cjs +75 -5
- package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +76 -3
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/loadGate.cjs +23 -1
- package/src/main/lib/machineProfile.cjs +15 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerBatch.cjs +12 -1
- package/src/main/lib/schedulerConfig.cjs +37 -0
- package/src/main/lib/telemetryBoot.cjs +11 -8
- package/src/main/lib/telemetryClient.cjs +44 -2
- package/src/main/lib/telemetrySettings.cjs +20 -3
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1869 -193
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
- package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
- package/dist/assets/Permissions-DKoNVgzj.js +0 -3
- package/dist/assets/Scheduler-CbES7MC8.js +0 -14
- package/dist/assets/Settings-BX3FElXk.js +0 -3
- package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -148,6 +148,48 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
|
|
|
148
148
|
assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
test('reapDeadRunningJobs: a pidless row past grace with a landedCommit already recorded is routed to needs_review, not failed, naming the commit sha (PRD 1173)', async () => {
|
|
152
|
+
const projectCwd = path.join(tmpHome, 'c3-project-pidless-with-landed-commit');
|
|
153
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
154
|
+
registerActiveProject(projectCwd);
|
|
155
|
+
|
|
156
|
+
const staleStartedAt = new Date(Date.now() - PIDLESS_SPAWN_GRACE_MS - 60_000).toISOString();
|
|
157
|
+
const landedSha = 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f';
|
|
158
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
159
|
+
{
|
|
160
|
+
slug: 'shipped-but-pidless',
|
|
161
|
+
status: 'running',
|
|
162
|
+
cwd: projectCwd,
|
|
163
|
+
runId: 'run-shipped-but-pidless',
|
|
164
|
+
startedAt: staleStartedAt,
|
|
165
|
+
landedCommit: landedSha,
|
|
166
|
+
// no runtime key at all — the spawn never got far enough to record one
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
// Empty run dir, same shape as the sibling 'failed' test below — the
|
|
170
|
+
// landedCommit evidence must be what changes the outcome, not the log.
|
|
171
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', 'run-shipped-but-pidless'), { recursive: true });
|
|
172
|
+
|
|
173
|
+
await reapDeadRunningJobs();
|
|
174
|
+
|
|
175
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
176
|
+
const row = jobs.find((j) => j.slug === 'shipped-but-pidless');
|
|
177
|
+
assert.equal(row.status, 'needs_review', 'a row with proof of landed work must never be transitioned to failed by the pidless branch');
|
|
178
|
+
assert.equal(row.landedCommit, landedSha, 'the pre-existing landedCommit must be preserved on the row');
|
|
179
|
+
assert.match(row.error, new RegExp(landedSha), 'the reason must name the recorded commit sha');
|
|
180
|
+
assert.equal(row.verifierVerdict, 'pidless_reap_with_landed_commit');
|
|
181
|
+
|
|
182
|
+
// Scan the whole audit log for THIS slug's event rather than slicing by a
|
|
183
|
+
// byte offset from statSync — a prior test's audit entries can contain
|
|
184
|
+
// multi-byte characters (e.g. an em dash in a reaper reason string), and
|
|
185
|
+
// slicing a utf8-decoded JS string (UTF-16 code units) at a byte offset
|
|
186
|
+
// then silently corrupts the boundary. The slug is unique to this test.
|
|
187
|
+
const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
188
|
+
const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'shipped-but-pidless');
|
|
189
|
+
assert.ok(pidlessEvent, 'reaping this row must still leave an audit trace');
|
|
190
|
+
assert.equal(pidlessEvent.landedCommit, landedSha, 'the audit event must carry the landed-commit evidence so the decision is reconstructable from the audit log alone');
|
|
191
|
+
});
|
|
192
|
+
|
|
151
193
|
test('reapDeadRunningJobs clears runId on a pidless reap whose run dir holds only a sibling slug\'s files', async () => {
|
|
152
194
|
const projectCwd = path.join(tmpHome, 'c2-project-batch-sibling');
|
|
153
195
|
fs.mkdirSync(projectCwd, { recursive: true });
|
|
@@ -573,3 +615,121 @@ test('reapDeadRunningJobs: a non-git cwd skips the integration check entirely, p
|
|
|
573
615
|
assert.equal(row.status, 'completed', 'a non-git cwd must keep today\'s behaviour — the check is meaningless there');
|
|
574
616
|
assert.equal(row.verifierVerdict, undefined);
|
|
575
617
|
});
|
|
618
|
+
|
|
619
|
+
// ---------- Reaper landed-commit evidence gate (job 1192 incident) ----------
|
|
620
|
+
//
|
|
621
|
+
// job 1192 shipped a real 3-file commit (7bf5fa3f...) and was still
|
|
622
|
+
// stamped 'failed' by reapDeadRunningJobs because a `landedCommit` field
|
|
623
|
+
// being present was never actually verified against the repo. These cover
|
|
624
|
+
// the dead-pid branch specifically (runtime.pid recorded, process
|
|
625
|
+
// confirmed dead) rather than the pidless branch: a pidless row with a
|
|
626
|
+
// non-empty landedCommit is already fully owned by PRD 1173's
|
|
627
|
+
// resolvePidlessFailureOverride (routes to needs_review, tested above) —
|
|
628
|
+
// this PRD's gate exists for the shape 1173 doesn't cover, where the
|
|
629
|
+
// reaper would otherwise fall straight through to 'failed' with no
|
|
630
|
+
// evidence check of any kind.
|
|
631
|
+
|
|
632
|
+
test('reapDeadRunningJobs: a dead-pid reap with a git-resolvable landedCommit transitions to completed, naming the reap cause and the evidence, source reapDeadRunningJobs:landed', async () => {
|
|
633
|
+
const projectCwd = path.join(tmpHome, 'n-project-landed-commit-evidence');
|
|
634
|
+
initRepo(projectCwd);
|
|
635
|
+
registerActiveProject(projectCwd);
|
|
636
|
+
|
|
637
|
+
fs.writeFileSync(path.join(projectCwd, 'shipped.txt'), 'done\n', 'utf8');
|
|
638
|
+
git(['add', '-A'], projectCwd);
|
|
639
|
+
git(['commit', '-q', '-m', 'ship the PRD work'], projectCwd);
|
|
640
|
+
const landedSha = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
641
|
+
|
|
642
|
+
const runId = 'run-landed-commit-evidence';
|
|
643
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
644
|
+
{
|
|
645
|
+
slug: 'landed-but-reaped',
|
|
646
|
+
status: 'running',
|
|
647
|
+
cwd: projectCwd,
|
|
648
|
+
runId,
|
|
649
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid
|
|
650
|
+
landedCommit: landedSha,
|
|
651
|
+
},
|
|
652
|
+
]);
|
|
653
|
+
// Empty run dir → classifyRunOutcome finds no result event → 'no_result'
|
|
654
|
+
// → without this PRD's gate this would stamp 'failed' despite the real
|
|
655
|
+
// commit already sitting on HEAD.
|
|
656
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
|
|
657
|
+
|
|
658
|
+
bustCwdCache();
|
|
659
|
+
await reapDeadRunningJobs();
|
|
660
|
+
|
|
661
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
662
|
+
const row = jobs.find((j) => j.slug === 'landed-but-reaped');
|
|
663
|
+
assert.equal(row.status, 'completed', 'a resolvable landedCommit must promote the reap to completed, not failed');
|
|
664
|
+
assert.equal(row.landedCommit, landedSha);
|
|
665
|
+
assert.equal(row.exitCode, 0);
|
|
666
|
+
assert.equal(row.error, null);
|
|
667
|
+
const lastTransition = row.statusHistory[row.statusHistory.length - 1];
|
|
668
|
+
assert.equal(lastTransition.source, 'reapDeadRunningJobs:landed');
|
|
669
|
+
assert.match(lastTransition.reason, /process gone/, 'the reap cause must still be named');
|
|
670
|
+
assert.match(lastTransition.reason, new RegExp(landedSha), 'the evidence sha must be named');
|
|
671
|
+
assert.match(lastTransition.reason, /resolves/);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test('reapDeadRunningJobs: a dead-pid reap with an unresolvable (stale) landedCommit keeps today\'s behaviour exactly — failed, same reason shape, same source', async () => {
|
|
675
|
+
const projectCwd = path.join(tmpHome, 'o-project-stale-landed-commit');
|
|
676
|
+
initRepo(projectCwd);
|
|
677
|
+
registerActiveProject(projectCwd);
|
|
678
|
+
|
|
679
|
+
const staleSha = '0123456789abcdef0123456789abcdef01234567'; // well-formed sha, never committed anywhere
|
|
680
|
+
const runId = 'run-stale-landed-commit';
|
|
681
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
682
|
+
{
|
|
683
|
+
slug: 'stale-landed-commit',
|
|
684
|
+
status: 'running',
|
|
685
|
+
cwd: projectCwd,
|
|
686
|
+
runId,
|
|
687
|
+
runtime: { pid: 999999 },
|
|
688
|
+
landedCommit: staleSha,
|
|
689
|
+
},
|
|
690
|
+
]);
|
|
691
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
|
|
692
|
+
|
|
693
|
+
bustCwdCache();
|
|
694
|
+
await reapDeadRunningJobs();
|
|
695
|
+
|
|
696
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
697
|
+
const row = jobs.find((j) => j.slug === 'stale-landed-commit');
|
|
698
|
+
assert.equal(row.status, 'failed', 'a landedCommit that does not resolve in the repo must never be trusted as evidence');
|
|
699
|
+
assert.equal(row.landedCommit, staleSha, 'the stale field is left on the row untouched, never cleared');
|
|
700
|
+
assert.match(row.error, /reaped: process gone \(outcome=no_result\)/);
|
|
701
|
+
assert.doesNotMatch(row.error, /resolves/);
|
|
702
|
+
const lastTransition = row.statusHistory[row.statusHistory.length - 1];
|
|
703
|
+
assert.equal(lastTransition.source, 'reapDeadRunningJobs', 'no evidence found — source must stay the plain reaper source, not :landed');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test('reapDeadRunningJobs: a landedCommit that cannot be git-resolved because the row cwd is not a usable git repo falls back to failed without throwing out of the reaper', async () => {
|
|
707
|
+
const projectCwd = path.join(tmpHome, 'p-project-unresolvable-cwd');
|
|
708
|
+
// Deliberately NOT initRepo(projectCwd) — no `.git` at all, the same
|
|
709
|
+
// "cwd git resolution cannot possibly succeed" shape a deleted/torn-down
|
|
710
|
+
// worktree checkout would produce (git itself errors identically for
|
|
711
|
+
// "not a git repository" and "no such directory" — both must be caught
|
|
712
|
+
// and treated as no-evidence, never thrown out of the reaper).
|
|
713
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
714
|
+
registerActiveProject(projectCwd);
|
|
715
|
+
|
|
716
|
+
const runId = 'run-unresolvable-cwd';
|
|
717
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
718
|
+
{
|
|
719
|
+
slug: 'unresolvable-cwd-job',
|
|
720
|
+
status: 'running',
|
|
721
|
+
cwd: projectCwd,
|
|
722
|
+
runId,
|
|
723
|
+
runtime: { pid: 999999 },
|
|
724
|
+
landedCommit: '89abcdef89abcdef89abcdef89abcdef89abcdef',
|
|
725
|
+
},
|
|
726
|
+
]);
|
|
727
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
|
|
728
|
+
|
|
729
|
+
bustCwdCache();
|
|
730
|
+
await assert.doesNotReject(() => reapDeadRunningJobs(), 'a bounded git spawn failure/timeout must never escape the reaper as an unhandled rejection');
|
|
731
|
+
|
|
732
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
733
|
+
const row = jobs.find((j) => j.slug === 'unresolvable-cwd-job');
|
|
734
|
+
assert.equal(row.status, 'failed', 'an unresolvable cwd must fall back to failed, exactly like no evidence at all');
|
|
735
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Ported from the retired top-level test-root directory (a node:test suite,
|
|
4
|
+
// never runnable by this repo's vitest-only setup — see CLAUDE.md's Commands
|
|
5
|
+
// section). The existing src/main/lib/__tests__/reaperHelpers.test.cjs
|
|
6
|
+
// exercises classifyRunOutcome extensively via selectReapableJobs and the
|
|
7
|
+
// rate-limit paths, but never asserts claudePidAlive directly (only ever
|
|
8
|
+
// injects a stand-in `pidAlive` function), and never pins the bare
|
|
9
|
+
// no-result/empty-file/malformed-event edge cases below. These fill that
|
|
10
|
+
// gap.
|
|
11
|
+
//
|
|
12
|
+
// Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs
|
|
13
|
+
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const { claudePidAlive, classifyRunOutcome } = require('../lib/reaperHelpers.cjs');
|
|
19
|
+
|
|
20
|
+
// ─────────────────────────────────────────── claudePidAlive
|
|
21
|
+
|
|
22
|
+
test('claudePidAlive: nonexistent pid (999999) returns false', () => {
|
|
23
|
+
assert.equal(claudePidAlive(999999), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('claudePidAlive: pid 0 returns false', () => {
|
|
27
|
+
assert.equal(claudePidAlive(0), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('claudePidAlive: pid 1 returns false', () => {
|
|
31
|
+
assert.equal(claudePidAlive(1), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('claudePidAlive: null returns false', () => {
|
|
35
|
+
assert.equal(claudePidAlive(null), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('claudePidAlive: undefined returns false', () => {
|
|
39
|
+
assert.equal(claudePidAlive(undefined), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────── classifyRunOutcome
|
|
43
|
+
|
|
44
|
+
function writeTempLog(content) {
|
|
45
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reaper-basics-test-'));
|
|
46
|
+
const filePath = path.join(dir, 'test.log');
|
|
47
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
48
|
+
return filePath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('classifyRunOutcome: success without an is_error field → success', () => {
|
|
52
|
+
const p = writeTempLog('{"type":"result","subtype":"success","result":"ok"}\n');
|
|
53
|
+
assert.equal(classifyRunOutcome(p), 'success');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('classifyRunOutcome: subtype success but is_error:true → failed (error wins)', () => {
|
|
57
|
+
const p = writeTempLog('{"type":"result","subtype":"success","is_error":true}\n');
|
|
58
|
+
assert.equal(classifyRunOutcome(p), 'failed');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('classifyRunOutcome: no result event in the tail → no_result', () => {
|
|
62
|
+
const log = [
|
|
63
|
+
'{"type":"assistant","message":"working"}',
|
|
64
|
+
'{"type":"tool_use","name":"bash"}',
|
|
65
|
+
'some plain text log line',
|
|
66
|
+
].join('\n');
|
|
67
|
+
const p = writeTempLog(log);
|
|
68
|
+
assert.equal(classifyRunOutcome(p), 'no_result');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('classifyRunOutcome: empty file → no_result', () => {
|
|
72
|
+
const p = writeTempLog('');
|
|
73
|
+
assert.equal(classifyRunOutcome(p), 'no_result');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('classifyRunOutcome: last result event wins when multiple exist', () => {
|
|
77
|
+
const log = [
|
|
78
|
+
'{"type":"result","subtype":"success"}',
|
|
79
|
+
'{"type":"result","subtype":"error","is_error":true}',
|
|
80
|
+
].join('\n');
|
|
81
|
+
const p = writeTempLog(log);
|
|
82
|
+
assert.equal(classifyRunOutcome(p), 'failed');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('classifyRunOutcome: nonexistent file → no_result (readTail swallows I/O errors)', () => {
|
|
86
|
+
assert.equal(classifyRunOutcome('/nonexistent/path/that/cannot/exist.log'), 'no_result');
|
|
87
|
+
});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
18
|
import { test, expect, vi, afterEach } from 'vitest';
|
|
19
|
+
const fs = require('node:fs');
|
|
19
20
|
const scheduler = require('../scheduler.cjs');
|
|
20
21
|
const { evaluateSharedTreeGuard, checkSharedTreeGuard } = scheduler;
|
|
21
22
|
|
|
@@ -210,3 +211,90 @@ test('checkSharedTreeGuard never throws when the underlying git calls reject', a
|
|
|
210
211
|
});
|
|
211
212
|
expect(result).toBeNull();
|
|
212
213
|
});
|
|
214
|
+
|
|
215
|
+
// --- 2026-09-12 false-positive: an untracked path that merely became
|
|
216
|
+
// IGNORED during the run (a .gitignore edit) is not a revert. See the
|
|
217
|
+
// evaluateSharedTreeGuard doc comment for the full incident writeup. Each
|
|
218
|
+
// test below is written to FAIL against the pre-fix implementation (which
|
|
219
|
+
// computed `reverted = dirtyBefore - dirtyAfter - committed` with no
|
|
220
|
+
// existence/tracked-status distinction at all) and PASS after it.
|
|
221
|
+
|
|
222
|
+
test('evaluateSharedTreeGuard: an untracked path that disappears from git status but STILL EXISTS on disk is nowIgnored, not reverted', () => {
|
|
223
|
+
// Pre-fix behavior: this path would land in `reverted` (dirtyBefore minus
|
|
224
|
+
// dirtyAfter minus committed, no existence check at all) — FAILS pre-fix.
|
|
225
|
+
const result = evaluateSharedTreeGuard({
|
|
226
|
+
stashBefore: [],
|
|
227
|
+
stashAfter: [],
|
|
228
|
+
dirtyBefore: [{ code: '??', path: 'session-manager-operations/logs/errors-2026-09-12.jsonl' }],
|
|
229
|
+
dirtyAfter: [],
|
|
230
|
+
pathsCommittedDuringRun: ['.gitignore'],
|
|
231
|
+
existsAfter: ['session-manager-operations/logs/errors-2026-09-12.jsonl'],
|
|
232
|
+
});
|
|
233
|
+
expect(result.reverted).toEqual([]);
|
|
234
|
+
expect(result.nowIgnored).toEqual(['session-manager-operations/logs/errors-2026-09-12.jsonl']);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('evaluateSharedTreeGuard: an untracked path that disappears from git status AND no longer exists on disk is still reverted', () => {
|
|
238
|
+
// Proves the fix is not a blanket "stop reporting reverted": a genuinely
|
|
239
|
+
// deleted untracked file (the guard's real purpose — catching a job that
|
|
240
|
+
// discarded uncommitted work) must still surface. If the fix had simply
|
|
241
|
+
// stopped reporting any untracked-and-missing path as reverted, this test
|
|
242
|
+
// would fail (reverted would come back empty instead of containing the
|
|
243
|
+
// path).
|
|
244
|
+
const result = evaluateSharedTreeGuard({
|
|
245
|
+
stashBefore: [],
|
|
246
|
+
stashAfter: [],
|
|
247
|
+
dirtyBefore: [{ code: '??', path: 'data/scratch.txt' }],
|
|
248
|
+
dirtyAfter: [],
|
|
249
|
+
pathsCommittedDuringRun: [],
|
|
250
|
+
existsAfter: [], // file no longer exists on disk
|
|
251
|
+
});
|
|
252
|
+
expect(result.reverted).toEqual(['data/scratch.txt']);
|
|
253
|
+
expect(result.nowIgnored).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('evaluateSharedTreeGuard: a TRACKED path clean after the run is still reverted even though it exists on disk', () => {
|
|
257
|
+
// A tracked file leaving the dirty set means its content was restored to
|
|
258
|
+
// HEAD — exactly the discard this guard exists to catch — regardless of
|
|
259
|
+
// whether the file is still present. Pre-fix this already worked by
|
|
260
|
+
// accident (no existence check existed at all); this test pins the
|
|
261
|
+
// behavior now that existence checks exist for the untracked branch, so a
|
|
262
|
+
// future change can't accidentally start trusting `existsAfter` for
|
|
263
|
+
// tracked paths too.
|
|
264
|
+
const result = evaluateSharedTreeGuard({
|
|
265
|
+
stashBefore: [],
|
|
266
|
+
stashAfter: [],
|
|
267
|
+
dirtyBefore: [{ code: ' M', path: 'src/config.js' }],
|
|
268
|
+
dirtyAfter: [],
|
|
269
|
+
pathsCommittedDuringRun: [],
|
|
270
|
+
existsAfter: ['src/config.js'], // file exists, but content was reset to HEAD
|
|
271
|
+
});
|
|
272
|
+
expect(result.reverted).toEqual(['src/config.js']);
|
|
273
|
+
expect(result.nowIgnored).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('checkSharedTreeGuard end-to-end: the real 2026-09-12 incident shape — untracked logs path ignored by a committed .gitignore change, file still on disk — must NOT verdict shared_tree_reverted', async () => {
|
|
277
|
+
// PRD 1181's exact shape: a bare `logs/` pattern lands in .gitignore during
|
|
278
|
+
// the run (committed), eleven untracked log paths vanish from `git status`,
|
|
279
|
+
// and the files are untouched on disk. Pre-fix, checkSharedTreeGuard's
|
|
280
|
+
// `reverted` would contain this path (no existsAfter computation existed),
|
|
281
|
+
// driving the caller's `verdict: 'shared_tree_reverted'` downgrade — FAILS
|
|
282
|
+
// pre-fix.
|
|
283
|
+
const logPath = 'session-manager-operations/logs/errors-2026-09-12.jsonl';
|
|
284
|
+
vi.spyOn(scheduler, 'stashList').mockResolvedValue([]);
|
|
285
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha-after');
|
|
286
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue(['.gitignore']);
|
|
287
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]); // the log path no longer shows up at all
|
|
288
|
+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => String(p).includes('errors-2026-09-12.jsonl'));
|
|
289
|
+
|
|
290
|
+
const result = await checkSharedTreeGuard({
|
|
291
|
+
cwd: '/repo',
|
|
292
|
+
stashBaseline: [],
|
|
293
|
+
dirtyBaseline: [{ code: '??', path: logPath }],
|
|
294
|
+
headBefore: 'sha-before',
|
|
295
|
+
slug: 'job-1181',
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(result.reverted).toBeUndefined();
|
|
299
|
+
expect(result.nowIgnored).toEqual([logPath]);
|
|
300
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-starve-escalation.test.cjs — the `project_starved` audit event
|
|
3
|
+
* (scheduler.cjs ~line 8888) used to be emit-only: it fired every sweep
|
|
4
|
+
* forever with zero consequence. The 2026-09-12 audit log showed
|
|
5
|
+
* /home/bilko/Projects/Bilko emitting it ~115 times over a 19h starve with
|
|
6
|
+
* nothing acting on it. runStarveEscalationSweep is the bounded, automated
|
|
7
|
+
* consequence: a starve outliving STARVE_ESCALATION_MS gets a DISTINCT
|
|
8
|
+
* 'project_starve_escalated' audit event (once per starve stretch, latched
|
|
9
|
+
* per cwd) plus a toast-channel error via the existing 'schedule:stall'
|
|
10
|
+
* push (already wired to toast.error on the renderer side).
|
|
11
|
+
*
|
|
12
|
+
* Exercises selectStarveEscalations() (pure) directly, and
|
|
13
|
+
* runStarveEscalationSweep() (the side-effecting wrapper) against a real,
|
|
14
|
+
* scratch audit-log.jsonl and a fake attached window — matching
|
|
15
|
+
* pollLoop-dispatch-on-failure.test.cjs's pattern of setting HOME before
|
|
16
|
+
* require() (beforeAll runs AFTER module evaluation) since every state path
|
|
17
|
+
* is baked from os.homedir() at load, and restoring it in afterAll.
|
|
18
|
+
*
|
|
19
|
+
* Run: timeout 180 npx vitest run src/main/__tests__/scheduler-starve-escalation.test.cjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
import { test, beforeEach, afterAll } from 'vitest';
|
|
25
|
+
const assert = require('node:assert/strict');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const originalHome = process.env.HOME;
|
|
31
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'starve-escalation-test-'));
|
|
32
|
+
process.env.HOME = tmpHome;
|
|
33
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
|
|
34
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
selectStarveEscalations,
|
|
38
|
+
runStarveEscalationSweep,
|
|
39
|
+
STARVE_ESCALATION_MS,
|
|
40
|
+
attachWindow,
|
|
41
|
+
} = require('../scheduler.cjs');
|
|
42
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
process.env.HOME = originalHome;
|
|
46
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const CWD_A = '/home/bilko/Projects/Bilko';
|
|
50
|
+
const CWD_B = '/home/bilko/Projects/session-manager';
|
|
51
|
+
|
|
52
|
+
function readAuditKind(kind) {
|
|
53
|
+
let lines;
|
|
54
|
+
try {
|
|
55
|
+
lines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').split('\n').filter(Boolean);
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return lines.map((l) => JSON.parse(l)).filter((r) => r.kind === kind);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fakeWindow() {
|
|
63
|
+
const sent = [];
|
|
64
|
+
return {
|
|
65
|
+
sent,
|
|
66
|
+
isDestroyed: () => false,
|
|
67
|
+
webContents: {
|
|
68
|
+
isDestroyed: () => false,
|
|
69
|
+
isCrashed: () => false,
|
|
70
|
+
send: (channel, payload) => sent.push({ channel, payload }),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
// Fresh scratch audit log per test so escalation counts never bleed across
|
|
77
|
+
// tests in this file.
|
|
78
|
+
fs.mkdirSync(path.dirname(AUDIT_LOG_PATH), { recursive: true });
|
|
79
|
+
fs.writeFileSync(AUDIT_LOG_PATH, '');
|
|
80
|
+
attachWindow(null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('selectStarveEscalations: escalates a row past threshold not already latched', () => {
|
|
84
|
+
const starved = [{ cwd: CWD_A, pendingCount: 3, oldestPendingSlug: 'x', ageMs: STARVE_ESCALATION_MS + 1 }];
|
|
85
|
+
const { toEscalate, toClear } = selectStarveEscalations(starved, new Set(), STARVE_ESCALATION_MS);
|
|
86
|
+
assert.equal(toEscalate.length, 1);
|
|
87
|
+
assert.equal(toEscalate[0].cwd, CWD_A);
|
|
88
|
+
assert.deepEqual(toClear, []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('selectStarveEscalations: never re-escalates a cwd already latched', () => {
|
|
92
|
+
const starved = [{ cwd: CWD_A, pendingCount: 3, oldestPendingSlug: 'x', ageMs: STARVE_ESCALATION_MS + 60_000 }];
|
|
93
|
+
const { toEscalate } = selectStarveEscalations(starved, new Set([CWD_A]), STARVE_ESCALATION_MS);
|
|
94
|
+
assert.deepEqual(toEscalate, []);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('selectStarveEscalations: a latched cwd no longer starved is returned for latch-clear', () => {
|
|
98
|
+
const { toEscalate, toClear } = selectStarveEscalations([], new Set([CWD_A]), STARVE_ESCALATION_MS);
|
|
99
|
+
assert.deepEqual(toEscalate, []);
|
|
100
|
+
assert.deepEqual(toClear, [CWD_A]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('selectStarveEscalations: below-threshold starve is neither escalated nor cleared', () => {
|
|
104
|
+
const starved = [{ cwd: CWD_A, pendingCount: 1, oldestPendingSlug: 'x', ageMs: STARVE_ESCALATION_MS - 1 }];
|
|
105
|
+
const { toEscalate, toClear } = selectStarveEscalations(starved, new Set(), STARVE_ESCALATION_MS);
|
|
106
|
+
assert.deepEqual(toEscalate, []);
|
|
107
|
+
assert.deepEqual(toClear, []);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('runStarveEscalationSweep: emits project_starve_escalated exactly once per starve stretch, plus a toast', () => {
|
|
111
|
+
const win = fakeWindow();
|
|
112
|
+
attachWindow(win);
|
|
113
|
+
const sp = { cwd: CWD_A, pendingCount: 4, oldestPendingSlug: 'oldest-a', ageMs: STARVE_ESCALATION_MS + 60_000 };
|
|
114
|
+
|
|
115
|
+
runStarveEscalationSweep([sp]);
|
|
116
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1);
|
|
117
|
+
assert.equal(win.sent.length, 1);
|
|
118
|
+
assert.equal(win.sent[0].channel, 'schedule:stall');
|
|
119
|
+
assert.match(win.sent[0].payload.message, /Bilko/);
|
|
120
|
+
|
|
121
|
+
const record = readAuditKind('project_starve_escalated')[0];
|
|
122
|
+
assert.equal(record.cwd, CWD_A);
|
|
123
|
+
assert.equal(record.pendingCount, 4);
|
|
124
|
+
assert.equal(record.oldestPendingSlug, 'oldest-a');
|
|
125
|
+
assert.equal(record.ageMs, sp.ageMs);
|
|
126
|
+
assert.ok(
|
|
127
|
+
['slots-exhausted', 'memory-deferred', 'load-deferred', 'held', 'unknown'].includes(record.holdReason),
|
|
128
|
+
`unexpected holdReason: ${record.holdReason}`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Same still-starved stretch on the NEXT sweep must not escalate again.
|
|
132
|
+
runStarveEscalationSweep([sp]);
|
|
133
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1);
|
|
134
|
+
assert.equal(win.sent.length, 1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('runStarveEscalationSweep: the latch resets once the starve clears, so a later stretch escalates again', () => {
|
|
138
|
+
const win = fakeWindow();
|
|
139
|
+
attachWindow(win);
|
|
140
|
+
const sp = { cwd: CWD_B, pendingCount: 2, oldestPendingSlug: 'oldest-b', ageMs: STARVE_ESCALATION_MS + 1 };
|
|
141
|
+
|
|
142
|
+
runStarveEscalationSweep([sp]);
|
|
143
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1);
|
|
144
|
+
|
|
145
|
+
// Starve clears: this cwd is no longer reported by findStarvedProjects at all.
|
|
146
|
+
runStarveEscalationSweep([]);
|
|
147
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1); // no new escalation on a clear pass
|
|
148
|
+
|
|
149
|
+
// A NEW starve stretch on the same cwd must escalate again — the latch
|
|
150
|
+
// must not have been permanently burned by the first stretch.
|
|
151
|
+
runStarveEscalationSweep([sp]);
|
|
152
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 2);
|
|
153
|
+
assert.equal(win.sent.length, 2);
|
|
154
|
+
});
|
|
@@ -187,6 +187,20 @@ test('a non-git cwd still falls back to running in place (unchanged behaviour
|
|
|
187
187
|
expect(row.worktreeFallbackReason).toMatch(/not a git repository/);
|
|
188
188
|
// Ran IN PLACE — the marker landed directly in projectCwd, not a worktree.
|
|
189
189
|
expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(true);
|
|
190
|
+
|
|
191
|
+
// A job losing worktree isolation must never be a silent downgrade
|
|
192
|
+
// discoverable only by reading queue.json afterwards — it must also land
|
|
193
|
+
// as a warn line in the durable ops error log, naming the slug, cwd, and
|
|
194
|
+
// reason.
|
|
195
|
+
const { todayFile } = require('../lib/opsErrorLog.cjs');
|
|
196
|
+
const errorLogPath = todayFile(projectCwd);
|
|
197
|
+
expect(fs.existsSync(errorLogPath)).toBe(true);
|
|
198
|
+
const logLines = fs.readFileSync(errorLogPath, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
|
199
|
+
const fallbackLine = logLines.find((l) => l.scope === 'scheduler' && l.level === 'warn' && l.message.includes(slug));
|
|
200
|
+
expect(fallbackLine).toBeTruthy();
|
|
201
|
+
expect(fallbackLine.message).toMatch(/SHARED working tree/);
|
|
202
|
+
expect(fallbackLine.message).toMatch(/not a git repository/);
|
|
203
|
+
expect(fallbackLine.meta).toMatchObject({ slug, cwd: projectCwd, reason: expect.stringMatching(/not a git repository/) });
|
|
190
204
|
} finally {
|
|
191
205
|
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
192
206
|
fs.rmSync(runDir, { recursive: true, force: true });
|
|
@@ -150,6 +150,26 @@ test('ingress functions never throw on hostile inputs', async () => {
|
|
|
150
150
|
client.shutdown();
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
// ─── reportInstall failure visibility ──────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('reportInstall logs a warn line naming the thrown error instead of swallowing it silently', async () => {
|
|
156
|
+
const home = await mkHome();
|
|
157
|
+
const client = freshClient(home);
|
|
158
|
+
client._setMachineProfileBuilder(() => { throw new Error('boom-profile'); });
|
|
159
|
+
const warnLines = [];
|
|
160
|
+
client._setLogger((payload) => warnLines.push(payload));
|
|
161
|
+
|
|
162
|
+
const result = await client.reportInstall(fakeProfile());
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual({ accepted: false, reason: 'error' });
|
|
165
|
+
expect(warnLines).toHaveLength(1);
|
|
166
|
+
expect(warnLines[0].scope).toBe('telemetry');
|
|
167
|
+
expect(warnLines[0].level).toBe('warn');
|
|
168
|
+
expect(warnLines[0].message).toBe('reportInstall failed');
|
|
169
|
+
expect(warnLines[0].meta.error).toContain('boom-profile');
|
|
170
|
+
client.shutdown();
|
|
171
|
+
});
|
|
172
|
+
|
|
153
173
|
// ─── disabled at ingress ───────────────────────────────────────────────
|
|
154
174
|
|
|
155
175
|
test('when telemetry is disabled, ingress drops at the point of entry', async () => {
|
|
@@ -269,6 +289,55 @@ test('attribution fields are exempt from redaction/clamping and arrive byte-iden
|
|
|
269
289
|
client.shutdown();
|
|
270
290
|
});
|
|
271
291
|
|
|
292
|
+
// ─── env discriminator (wire-level, all four channels) ─────────────────
|
|
293
|
+
|
|
294
|
+
test('every channel carries env:"dev" when SM_DEV drives installChannel, and it survives the queue round-trip through flush', async () => {
|
|
295
|
+
const home = await mkHome();
|
|
296
|
+
const client = freshClient(home, { installChannel: 'dev' });
|
|
297
|
+
const fetchFn = fetchStub(async () => okResponse());
|
|
298
|
+
client._setFetchImpl(fetchFn);
|
|
299
|
+
|
|
300
|
+
await client.track('evt', { a: 1 });
|
|
301
|
+
await client.logLine({ level: 'info', msg: 'hi' });
|
|
302
|
+
await client.reportError({ name: 'E', msg: 'boom', stack: 'at x' });
|
|
303
|
+
await client.reportInstall(fakeProfile({ installChannel: 'dev' }));
|
|
304
|
+
|
|
305
|
+
// Present the instant it's appended to telemetry-queue.jsonl, before any flush.
|
|
306
|
+
const queued = await readQueueLines(client);
|
|
307
|
+
expect(queued).toHaveLength(4);
|
|
308
|
+
for (const rec of queued) expect(rec.wire.env).toBe('dev');
|
|
309
|
+
|
|
310
|
+
// Still present in the exact body flush() POSTs.
|
|
311
|
+
await client.flush('manual');
|
|
312
|
+
expect(fetchFn.calls.length).toBeGreaterThan(0);
|
|
313
|
+
for (const call of fetchFn.calls) {
|
|
314
|
+
const bodies = Array.isArray(call.body.batch) ? call.body.batch : [call.body];
|
|
315
|
+
for (const wire of bodies) expect(wire.env).toBe('dev');
|
|
316
|
+
}
|
|
317
|
+
client.shutdown();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('every channel carries env:"prod" for a non-dev installChannel', async () => {
|
|
321
|
+
const home = await mkHome();
|
|
322
|
+
const client = freshClient(home, { installChannel: 'npx' });
|
|
323
|
+
|
|
324
|
+
await client.track('evt', { a: 1 });
|
|
325
|
+
await client.logLine({ level: 'info', msg: 'hi' });
|
|
326
|
+
await client.reportError({ name: 'E', msg: 'boom', stack: 'at x' });
|
|
327
|
+
await client.reportInstall(fakeProfile({ installChannel: 'npx' }));
|
|
328
|
+
|
|
329
|
+
const queued = await readQueueLines(client);
|
|
330
|
+
expect(queued).toHaveLength(4);
|
|
331
|
+
for (const rec of queued) expect(rec.wire.env).toBe('prod');
|
|
332
|
+
client.shutdown();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('env resolves to "test" under a real test-runner environment, independent of installChannel', () => {
|
|
336
|
+
const machineProfile = require('../lib/machineProfile.cjs');
|
|
337
|
+
expect(machineProfile.resolveEnv({ isTestRunner: true, installChannel: 'dev' })).toBe('test');
|
|
338
|
+
expect(machineProfile.resolveEnv({ isTestRunner: true, installChannel: 'npx' })).toBe('test');
|
|
339
|
+
});
|
|
340
|
+
|
|
272
341
|
// ─── queue is the accumulator, survives restart ────────────────────────
|
|
273
342
|
|
|
274
343
|
test('a record survives a simulated process restart and is still pending', async () => {
|
|
@@ -532,11 +601,11 @@ test('wire field names match server/routes/telemetry.ts exactly', async () => {
|
|
|
532
601
|
const errorCall = fetchFn.calls.find((c) => c.url.endsWith('/error'));
|
|
533
602
|
const installCall = fetchFn.calls.find((c) => c.url.endsWith('/install'));
|
|
534
603
|
|
|
535
|
-
expect(Object.keys(eventCall.body.batch[0]).sort()).toEqual(['app', 'name', 'props', 'session_id', 'visitor_id'].sort());
|
|
536
|
-
expect(Object.keys(logCall.body.batch[0]).sort()).toEqual(['app', 'version', 'level', 'msg', 'visitor_id', 'session_id', 'fields', 'ts'].sort());
|
|
537
|
-
expect(Object.keys(errorCall.body.batch[0]).sort()).toEqual(['app', 'version', 'name', 'msg', 'stack', 'url', 'ua', 'visitor_id', 'session_id', 'context', 'ts'].sort());
|
|
604
|
+
expect(Object.keys(eventCall.body.batch[0]).sort()).toEqual(['app', 'env', 'name', 'props', 'session_id', 'visitor_id'].sort());
|
|
605
|
+
expect(Object.keys(logCall.body.batch[0]).sort()).toEqual(['app', 'env', 'version', 'level', 'msg', 'visitor_id', 'session_id', 'fields', 'ts'].sort());
|
|
606
|
+
expect(Object.keys(errorCall.body.batch[0]).sort()).toEqual(['app', 'env', 'version', 'name', 'msg', 'stack', 'url', 'ua', 'visitor_id', 'session_id', 'context', 'ts'].sort());
|
|
538
607
|
expect(Object.keys(installCall.body).sort()).toEqual([
|
|
539
|
-
'app', 'app_version', 'arch', 'cpu_count', 'electron_version',
|
|
608
|
+
'app', 'app_version', 'arch', 'cpu_count', 'electron_version', 'env',
|
|
540
609
|
'install_channel', 'install_id', 'locale', 'node_version',
|
|
541
610
|
'os_release', 'platform', 'timezone', 'total_mem_mb',
|
|
542
611
|
].sort());
|
|
@@ -854,7 +923,7 @@ test('reportInstall() maps buildMachineProfile()\'s camelCase onto the exact sna
|
|
|
854
923
|
|
|
855
924
|
expect(rec.channel).toBe('install');
|
|
856
925
|
expect(Object.keys(rec.wire).sort()).toEqual([
|
|
857
|
-
'app', 'app_version', 'arch', 'cpu_count', 'electron_version',
|
|
926
|
+
'app', 'app_version', 'arch', 'cpu_count', 'electron_version', 'env',
|
|
858
927
|
'install_channel', 'install_id', 'locale', 'node_version',
|
|
859
928
|
'os_release', 'platform', 'timezone', 'total_mem_mb',
|
|
860
929
|
].sort());
|
|
@@ -962,6 +1031,7 @@ test('a stub server bound to 127.0.0.1 receives exactly one well-formed install
|
|
|
962
1031
|
install_channel: profile.installChannel,
|
|
963
1032
|
locale: profile.locale,
|
|
964
1033
|
timezone: String(profile.timezoneOffsetMinutes),
|
|
1034
|
+
env: 'dev',
|
|
965
1035
|
});
|
|
966
1036
|
client.shutdown();
|
|
967
1037
|
} finally {
|