claude-code-session-manager 0.121.0 → 0.122.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-FO2ei2wB.js → AgentLibrary-C2qUX9WU.js} +1 -1
- package/dist/assets/{Button-CeIOXUPU.js → Button-ssBzvFha.js} +1 -1
- package/dist/assets/{DataModel-D0p__Hil.js → DataModel-CENoTvc2.js} +1 -1
- package/dist/assets/History-BRdRp1J_.js +2 -0
- package/dist/assets/{Hooks-DkTnRHyT.js → Hooks--hiGPBQy.js} +1 -1
- package/dist/assets/{Library-l7KS6pkE.js → Library-0XYvkhh2.js} +1 -1
- package/dist/assets/{ListDetail-DDw8fuRT.js → ListDetail-CkKXAM59.js} +1 -1
- package/dist/assets/{McpServers-UUNIxk_3.js → McpServers-CCjHSwFm.js} +1 -1
- package/dist/assets/{Memory-CNYUoQ8C.js → Memory-C7RXoVO4.js} +1 -1
- package/dist/assets/{Panel-Bb54soc2.js → Panel-C92O_aAH.js} +1 -1
- package/dist/assets/{Permissions-D-dNdV3f.js → Permissions-DuTJ1FUb.js} +1 -1
- package/dist/assets/Plugins-DSXsP4hM.js +2 -0
- package/dist/assets/{ProvenanceBadge-DEAkDNjA.js → ProvenanceBadge-CWdDkU1m.js} +1 -1
- package/dist/assets/{RemoteMachines-xCEgvTGU.js → RemoteMachines-BFmbIGpW.js} +1 -1
- package/dist/assets/{SaveBar-BSEs7ecA.js → SaveBar-ob2dJJjH.js} +1 -1
- package/dist/assets/Scheduler-Du4HgPKY.js +16 -0
- package/dist/assets/{ScopeSwitcher-DcaFyAV-.js → ScopeSwitcher-Dv_6M_Ci.js} +1 -1
- package/dist/assets/{Settings-vcdWy_Jx.js → Settings-DMoI0cqB.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CLrJWTPd.js → SkillReferenceGraph-Dx1Qs8Y0.js} +1 -1
- package/dist/assets/{Skills-CYM4rBEC.js → Skills-DYWQTQB1.js} +1 -1
- package/dist/assets/{SystemPrompt-BWNIu-iY.js → SystemPrompt-Df0IfOuI.js} +1 -1
- package/dist/assets/{TagLibrary-CYdwHK6m.js → TagLibrary-BuMsGx-2.js} +1 -1
- package/dist/assets/{TiptapBody-Kt90lHrD.js → TiptapBody-Cihe5er3.js} +1 -1
- package/dist/assets/{Toggle-D1OXWYOz.js → Toggle-CpYVC8bF.js} +1 -1
- package/dist/assets/{ViewTabs-BeQR3CKh.js → ViewTabs-C3FN6P_C.js} +1 -1
- package/dist/assets/{historyTotals-B0qafrRo.js → historyTotals-C4Rs37Cd.js} +1 -1
- package/dist/assets/{index-Bq9uH08R.js → index-S-F5jp_h.js} +396 -396
- package/dist/assets/{settingsSchema-0ZksSAPB.js → settingsSchema-BZcdzHTQ.js} +1 -1
- package/dist/assets/{skillFrontmatter-C0nh8yfa.js → skillFrontmatter-EfNERt4b.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-DPkoBybS.js → useAgentsDirWatch-BfbrFvB3.js} +1 -1
- package/dist/assets/{useRemotePeers-BDJMUihy.js → useRemotePeers-CKVakQPc.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +8 -7
- package/plugins/session-manager-dev/skills/develop/standards.md +4 -10
- package/scripts/scheduler-mcp-server.cjs +20 -6
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +13 -37
- package/src/main/docEdit.cjs +1 -0
- package/src/main/index.cjs +12 -13
- package/src/main/ipcSchemas.cjs +2 -2
- 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 +5 -8
- package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +11 -0
- package/src/main/lib/prd/prdCreate.cjs +48 -1
- package/src/main/lib/prd/prdSizing.cjs +1 -8
- 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 +116 -181
- 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/preload/api.d.ts +26 -6
- package/src/preload/index.cjs +1 -0
- package/src/seed/agents/dev-lead.md +2 -9
- package/dist/assets/History-DVqQ5azN.js +0 -2
- package/dist/assets/Plugins-DhcIXRhQ.js +0 -2
- package/dist/assets/Scheduler-BIKJIU-C.js +0 -16
|
@@ -303,18 +303,84 @@ async function checkSchedulerMcpLive({ cwd, homeDir, schedulerMcpCheck }) {
|
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
const DEV_PLUGIN_MARKETPLACE = 'session-manager';
|
|
307
|
+
const DEV_PLUGIN_REINSTALL_FIX = 'Restart Session Manager to reinstall the session-manager-dev plugin';
|
|
308
|
+
|
|
309
|
+
/** installPath of the dev plugin from installed_plugins.json, or null. */
|
|
310
|
+
function readDevPluginInstallPath(homeDir) {
|
|
311
|
+
const data = readJsonOrSync(path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'), null);
|
|
312
|
+
const entries = data?.plugins?.[DEV_PLUGIN_ENABLED_KEY];
|
|
313
|
+
if (!Array.isArray(entries)) return null;
|
|
314
|
+
for (const e of entries) {
|
|
315
|
+
if (e && typeof e.installPath === 'string' && e.installPath) return e.installPath;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Source path of the 'session-manager' marketplace from known_marketplaces.json, or null. */
|
|
321
|
+
function readDevMarketplacePath(homeDir) {
|
|
322
|
+
const data = readJsonOrSync(path.join(homeDir, '.claude', 'plugins', 'known_marketplaces.json'), null);
|
|
323
|
+
const m = data?.[DEV_PLUGIN_MARKETPLACE];
|
|
324
|
+
const p = m?.source?.path ?? m?.installLocation;
|
|
325
|
+
return typeof p === 'string' && p ? p : null;
|
|
326
|
+
}
|
|
327
|
+
|
|
306
328
|
function checkDevPlugin({ homeDir }) {
|
|
307
329
|
const settings = readJsonOrSync(path.join(homeDir, '.claude', 'settings.json'), null);
|
|
308
|
-
const
|
|
330
|
+
const enabled = settings?.enabledPlugins?.[DEV_PLUGIN_ENABLED_KEY] === true;
|
|
331
|
+
const base = { id: 'dev-plugin', label: 'session-manager-dev plugin enabled' };
|
|
332
|
+
|
|
333
|
+
if (!enabled) {
|
|
334
|
+
return {
|
|
335
|
+
...base,
|
|
336
|
+
ok: false,
|
|
337
|
+
detail: `${DEV_PLUGIN_ENABLED_KEY} missing from ~/.claude/settings.json enabledPlugins`,
|
|
338
|
+
fix: 'Enable in Plugins tab: session-manager-dev (Session Manager)',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const installPath = readDevPluginInstallPath(homeDir);
|
|
343
|
+
if (!installPath) {
|
|
344
|
+
return {
|
|
345
|
+
...base,
|
|
346
|
+
ok: false,
|
|
347
|
+
detail: `${DEV_PLUGIN_ENABLED_KEY} enabled but not recorded in ~/.claude/plugins/installed_plugins.json`,
|
|
348
|
+
fix: DEV_PLUGIN_REINSTALL_FIX,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const skillFile = path.join(installPath, 'skills', 'develop', 'SKILL.md');
|
|
352
|
+
if (!fs.existsSync(skillFile)) {
|
|
353
|
+
return {
|
|
354
|
+
...base,
|
|
355
|
+
ok: false,
|
|
356
|
+
detail: `${DEV_PLUGIN_ENABLED_KEY} enabled but ${skillFile} is missing`,
|
|
357
|
+
fix: DEV_PLUGIN_REINSTALL_FIX,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const marketplacePath = readDevMarketplacePath(homeDir);
|
|
362
|
+
if (!marketplacePath) {
|
|
363
|
+
return {
|
|
364
|
+
...base,
|
|
365
|
+
ok: false,
|
|
366
|
+
detail: `'${DEV_PLUGIN_MARKETPLACE}' marketplace not registered in ~/.claude/plugins/known_marketplaces.json`,
|
|
367
|
+
fix: DEV_PLUGIN_REINSTALL_FIX,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (!fs.existsSync(marketplacePath)) {
|
|
371
|
+
return {
|
|
372
|
+
...base,
|
|
373
|
+
ok: false,
|
|
374
|
+
detail: `'${DEV_PLUGIN_MARKETPLACE}' marketplace folder ${marketplacePath} no longer exists`,
|
|
375
|
+
fix: DEV_PLUGIN_REINSTALL_FIX,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
309
378
|
|
|
310
379
|
return {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
? `${DEV_PLUGIN_ENABLED_KEY} enabled in ~/.claude/settings.json`
|
|
316
|
-
: `${DEV_PLUGIN_ENABLED_KEY} missing from ~/.claude/settings.json enabledPlugins`,
|
|
317
|
-
fix: ok ? null : 'Enable in Plugins tab: session-manager-dev (Session Manager)',
|
|
380
|
+
...base,
|
|
381
|
+
ok: true,
|
|
382
|
+
detail: `${DEV_PLUGIN_ENABLED_KEY} enabled in ~/.claude/settings.json and installed on disk`,
|
|
383
|
+
fix: null,
|
|
318
384
|
};
|
|
319
385
|
}
|
|
320
386
|
|
|
@@ -795,6 +861,7 @@ async function ensureGuardsInstalled(cwd, { homeDir = os.homedir() } = {}) {
|
|
|
795
861
|
}
|
|
796
862
|
|
|
797
863
|
module.exports = {
|
|
864
|
+
checkDevPlugin,
|
|
798
865
|
checkDelegationReadiness,
|
|
799
866
|
ensureGuardsInstalled,
|
|
800
867
|
logAutoInstallEvent,
|
|
@@ -111,12 +111,10 @@ const MCP_TOOL_CATALOG = [
|
|
|
111
111
|
+ 'them with `dependsOn` instead.',
|
|
112
112
|
whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
|
|
113
113
|
whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
|
|
114
|
-
+ '(a) this tool call is PRESENT in your tool list but ERRORS
|
|
115
|
-
+ 'API unreachable
|
|
116
|
-
+ '
|
|
117
|
-
+ '
|
|
118
|
-
+ 'verification) since the server-side validation, atomic NN allocation, and '
|
|
119
|
-
+ 'Epic-existence check this tool performs did not run for that file. '
|
|
114
|
+
+ '(a) this tool call is PRESENT in your tool list but ERRORS — NEVER hand-write a PRD file. '
|
|
115
|
+
+ 'On "not running" (admin API unreachable): stop, ask the human to start the app, then retry the same call. '
|
|
116
|
+
+ 'On "did not answer": the app is busy and the PRD may already exist — check with scheduler_list_prds '
|
|
117
|
+
+ '(no status filter) or retry the same slug. '
|
|
120
118
|
+ '(b) this tool is ABSENT from your tool list entirely — you were never offered it, so '
|
|
121
119
|
+ 'there is no error to catch. That means the session-manager-scheduler MCP server is not '
|
|
122
120
|
+ 'registered for this project: a MISCONFIGURATION, not an offline app. In that case DO '
|
|
@@ -127,7 +125,6 @@ const MCP_TOOL_CATALOG = [
|
|
|
127
125
|
exampleArgs: {
|
|
128
126
|
title: 'Add unit tests for the retry backoff helper',
|
|
129
127
|
cwd: '/home/bilko/Projects/session-manager',
|
|
130
|
-
estimateMinutes: 10,
|
|
131
128
|
goal: 'Cover retryWithBackoff.cjs edge cases (zero retries, max-delay clamp) that currently have no test.',
|
|
132
129
|
acceptanceCriteria: ['New test file exercises zero-retry and max-delay-clamp cases', 'Typecheck passes', 'Unit tests pass'],
|
|
133
130
|
implementationNotes: 'See src/main/lib/retryWithBackoff.cjs and its existing __tests__ sibling for the pattern to extend.',
|
|
@@ -401,7 +398,7 @@ const MCP_RECIPES = [
|
|
|
401
398
|
title: 'Queue work into an already-approved Epic (/develop)',
|
|
402
399
|
steps: [
|
|
403
400
|
'Confirm an Epic already exists for this work (proposed or active) — scheduler_create_prd never mints one.',
|
|
404
|
-
'Call scheduler_create_prd with sourcePromptId set to that Epic id, plus title/cwd/
|
|
401
|
+
'Call scheduler_create_prd with sourcePromptId set to that Epic id, plus title/cwd/goal/acceptanceCriteria/implementationNotes/gate/files.',
|
|
405
402
|
'The response has enqueued:false — the queue row is derived by the scheduler\'s next reconcile pass (~1 minute), not by this call.',
|
|
406
403
|
'Optionally call scheduler_list_prds with cwd/status to confirm the PRD picked up a job row.',
|
|
407
404
|
],
|
|
@@ -124,6 +124,17 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
|
|
|
124
124
|
tools: "Read, Grep, Glob, Bash, Edit, Write",
|
|
125
125
|
},
|
|
126
126
|
},
|
|
127
|
+
{
|
|
128
|
+
commit: "2129verify",
|
|
129
|
+
bodySha256: "82b93c1da9a5e3b7dfcfadd1b4689d988a4bd19de2d897ebd3f3158969d741fc",
|
|
130
|
+
fm: {
|
|
131
|
+
description: "Executes exactly one already-scoped PRD at a time, headless, start to finish — reads the PRD's Goal/Acceptance Criteria/Implementation notes and standards.md, implements it, verifies against its own AC, and reports. Has no visibility into the overall plan — that's architect's job. This is the default persona a scheduled PRD runs as: the scheduler resolves a PRD's `agentType` frontmatter field (default `dev-lead`) to this file and launches the headless executor AS this persona via `--append-system-prompt`.",
|
|
132
|
+
model: "sonnet",
|
|
133
|
+
effort: "medium",
|
|
134
|
+
title: "Engineering — Software Engineer",
|
|
135
|
+
tools: "Read, Grep, Glob, Bash, Edit, Write",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
127
138
|
],
|
|
128
139
|
"project-home-builder": [
|
|
129
140
|
{
|
|
@@ -32,6 +32,7 @@ const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
|
|
|
32
32
|
const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
|
|
33
33
|
const { isFixPlanSlug } = require('../scheduler/fixPlanSlug.cjs');
|
|
34
34
|
const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
|
|
35
|
+
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
35
36
|
const { sizingWarnings } = require('./prdSizing.cjs');
|
|
36
37
|
const { execGit } = require('../git/gitExec.cjs');
|
|
37
38
|
const { extractRepoPathCandidates, worktreeReachWarnings } = require('./prdWorktreeReach.cjs');
|
|
@@ -88,7 +89,9 @@ function buildPrdBody(input) {
|
|
|
88
89
|
// No `parallelGroup` frontmatter key (PRD_AUTHORING.md §5) — the NN-
|
|
89
90
|
// filename prefix is the single source of truth for grouping; adding a
|
|
90
91
|
// second one here would let the two drift out of sync.
|
|
91
|
-
const fmLines = ['---', `title: ${title}`, `cwd: ${cwd}
|
|
92
|
+
const fmLines = ['---', `title: ${title}`, `cwd: ${cwd}`];
|
|
93
|
+
// Optional: only a finite positive integer is written; absent => budget's 45-min floor.
|
|
94
|
+
if (Number.isInteger(estimateMinutes) && estimateMinutes > 0) fmLines.push(`estimateMinutes: ${estimateMinutes}`);
|
|
92
95
|
// Provenance stamp (PRD-authoring lockdown): every PRD written through this
|
|
93
96
|
// function — the sole in-process implementation behind both the admin
|
|
94
97
|
// HTTP create-prd route and the renderer's chat:create-prd IPC handler —
|
|
@@ -222,7 +225,18 @@ async function worktreeReachAdvisory(input, cwd) {
|
|
|
222
225
|
* on failure — `status` is the HTTP status code the caller should map errors to
|
|
223
226
|
* (400/409/500), left to the caller since only the HTTP route needs it.
|
|
224
227
|
*/
|
|
228
|
+
const inFlightCreates = new Set();
|
|
229
|
+
|
|
225
230
|
async function createPrd(input, remote) {
|
|
231
|
+
const ctl = { key: null };
|
|
232
|
+
try {
|
|
233
|
+
return await createPrdInner(input, remote, ctl);
|
|
234
|
+
} finally {
|
|
235
|
+
if (ctl.key) inFlightCreates.delete(ctl.key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function createPrdInner(input, remote, ctl) {
|
|
226
240
|
// cwd is untrusted AND, since this PRD, optional — a caller inside an
|
|
227
241
|
// Epic's git worktree naturally passes its worktree pwd (or omits cwd
|
|
228
242
|
// entirely and relies on originClaudeSessionId/sourcePromptId). Resolve the
|
|
@@ -448,6 +462,16 @@ async function createPrd(input, remote) {
|
|
|
448
462
|
// Finished rows leave queue.json, so an author can re-issue a landed PRD under a fresh NN.
|
|
449
463
|
// Refused before NN allocation so it burns no number. Fix plans are exempt.
|
|
450
464
|
if (input.sourcePromptId && !isFixPlanSlug(`0-${slug}`)) {
|
|
465
|
+
const inFlightKey = `${input.sourcePromptId}\u0000${slug}`;
|
|
466
|
+
if (inFlightCreates.has(inFlightKey)) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
status: 409,
|
|
470
|
+
error: `A create for slug "${slug}" in this Epic is still in progress — wait for it to finish, then retry the same call`,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
inFlightCreates.add(inFlightKey);
|
|
474
|
+
ctl.key = inFlightKey;
|
|
451
475
|
let epicDirs = [];
|
|
452
476
|
try {
|
|
453
477
|
const prdsDir = resolveEpicPrdWriteDir(cwd, input.sourcePromptId);
|
|
@@ -458,6 +482,28 @@ async function createPrd(input, remote) {
|
|
|
458
482
|
try { names = await fsp.readdir(dir); } catch { /* missing dir counts as empty */ }
|
|
459
483
|
const dup = names.find((n) => n.endsWith('.md') && n.slice(0, -3).replace(/^\d+-/, '') === slug);
|
|
460
484
|
if (dup) {
|
|
485
|
+
let existingTitle = null;
|
|
486
|
+
try {
|
|
487
|
+
existingTitle = splitFrontmatter(await fsp.readFile(path.join(dir, dup), 'utf8')).fm.title ?? null;
|
|
488
|
+
} catch { /* unreadable — treat as different */ }
|
|
489
|
+
if (existingTitle !== null && String(existingTitle).trim() === String(input.title ?? '').trim()) {
|
|
490
|
+
const existingSlug = dup.slice(0, -3);
|
|
491
|
+
let enqueuedExisting = false;
|
|
492
|
+
try {
|
|
493
|
+
if (typeof remote.getJob === 'function') enqueuedExisting = Boolean(await remote.getJob(existingSlug));
|
|
494
|
+
} catch { /* non-fatal */ }
|
|
495
|
+
return {
|
|
496
|
+
ok: true,
|
|
497
|
+
alreadyExisted: true,
|
|
498
|
+
nn: Number((/^(\d+)-/.exec(dup) || [])[1]),
|
|
499
|
+
filename: dup,
|
|
500
|
+
prdPath: path.join(dir, dup),
|
|
501
|
+
epicId: input.sourcePromptId,
|
|
502
|
+
enqueued: enqueuedExisting,
|
|
503
|
+
note: 'PRD was not written again: this Epic already has that file with the same title, which usually means an earlier call\'s response was lost, so treat it as created.',
|
|
504
|
+
warnings: [],
|
|
505
|
+
};
|
|
506
|
+
}
|
|
461
507
|
return {
|
|
462
508
|
ok: false,
|
|
463
509
|
status: 409,
|
|
@@ -718,6 +764,7 @@ function registerAdminRoute(adminHttp, remote) {
|
|
|
718
764
|
// Mirror createPrd()'s own honest shape verbatim — never re-add a
|
|
719
765
|
// 'status' field here. See createPrd's return above for why.
|
|
720
766
|
sendJson(res, 200, {
|
|
767
|
+
...(result.alreadyExisted ? { alreadyExisted: true } : {}),
|
|
721
768
|
nn: result.nn,
|
|
722
769
|
filename: result.filename,
|
|
723
770
|
prdPath: result.prdPath ?? null,
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
13
|
const SIZING_LIMITS = {
|
|
14
|
-
estimateMinutes: 15,
|
|
15
14
|
acLines: 8,
|
|
16
15
|
acLineChars: 400,
|
|
17
16
|
bodyChars: 7000,
|
|
@@ -30,18 +29,12 @@ const TIMEOUT_CMD_RE = /\btimeout\s+\d+/gi;
|
|
|
30
29
|
* treated as empty/absent rather than thrown on, since this is advisory only.
|
|
31
30
|
*/
|
|
32
31
|
function sizingWarnings(input) {
|
|
33
|
-
const {
|
|
32
|
+
const { goal, implementationNotes } = input || {};
|
|
34
33
|
const acceptanceCriteria = Array.isArray(input?.acceptanceCriteria) ? input.acceptanceCriteria : [];
|
|
35
34
|
const acLines = acceptanceCriteria.filter((line) => typeof line === 'string');
|
|
36
35
|
|
|
37
36
|
const warnings = [];
|
|
38
37
|
|
|
39
|
-
if (typeof estimateMinutes === 'number' && estimateMinutes > SIZING_LIMITS.estimateMinutes) {
|
|
40
|
-
warnings.push(
|
|
41
|
-
`estimateMinutes (${estimateMinutes}) exceeds the ${SIZING_LIMITS.estimateMinutes}-minute sizing limit — split this PRD into smaller work items.`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
38
|
if (acceptanceCriteria.length > SIZING_LIMITS.acLines) {
|
|
46
39
|
warnings.push(
|
|
47
40
|
`acceptanceCriteria has ${acceptanceCriteria.length} lines, exceeding the ${SIZING_LIMITS.acLines}-line sizing limit — split into smaller, single-purpose PRDs.`,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Runs first-boot seeders one at a time. `claude plugin install` and
|
|
2
|
+
// `claude mcp add --scope user` both rewrite Claude's config files; run
|
|
3
|
+
// concurrently, one write can drop the other's entry after its marker says done.
|
|
4
|
+
// A failing step is logged and never skips the steps after it.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Array<{ name: string, run: () => Promise<unknown> }>} steps
|
|
8
|
+
* @returns {Promise<void>}
|
|
9
|
+
*/
|
|
10
|
+
async function runSeedersSerially(steps) {
|
|
11
|
+
for (const step of steps) {
|
|
12
|
+
try {
|
|
13
|
+
await step.run();
|
|
14
|
+
} catch (e) {
|
|
15
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
16
|
+
console.error(`[seed] ${step.name} failed: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { runSeedersSerially };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loopEfficiency.cjs — pure calculator for the scheduler's real throughput
|
|
3
|
+
* metric: validated PRDs per unit of usage, per project.
|
|
4
|
+
*
|
|
5
|
+
* Why: the scheduler is bound by the account's usage window, not by slots, so
|
|
6
|
+
* "verified PRDs per usage %" (and $ per verified PRD) is what matters. Also
|
|
7
|
+
* makes the "wasted runs <= 5%" target in scheduler-operations.md computable.
|
|
8
|
+
*
|
|
9
|
+
* Pure: no I/O, no Date.now(), no mutation of inputs.
|
|
10
|
+
* Complexity O(R + V + P log P): one pass over runs, one over validations,
|
|
11
|
+
* then a sort of the P project rows.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { COMPLETED_EQUIVALENT_VERDICTS } = require('./terminalRunOutcome.cjs');
|
|
18
|
+
|
|
19
|
+
/** @param {unknown} t @param {number} fromMs @param {number} toMs */
|
|
20
|
+
function inWindow(t, fromMs, toMs) {
|
|
21
|
+
return typeof t === 'number' && Number.isFinite(t) && t >= fromMs && t < toMs;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} slug */
|
|
25
|
+
function normSlug(slug) {
|
|
26
|
+
return String(slug == null ? '' : slug).replace(/^\d+-/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{
|
|
31
|
+
* runs?: Array<{ cwd: string, slug: string, startedAtMs: number, costUsd?: number, verdict: string | null }>,
|
|
32
|
+
* validations?: Array<{ cwd: string, slug: string, verdict: 'VERIFIED' | 'REFUTED', atMs: number }>,
|
|
33
|
+
* fromMs: number,
|
|
34
|
+
* toMs: number,
|
|
35
|
+
* weeklyUtilizationPct?: number | null,
|
|
36
|
+
* }} input
|
|
37
|
+
*/
|
|
38
|
+
function computeLoopEfficiency({ runs, validations, fromMs, toMs, weeklyUtilizationPct }) {
|
|
39
|
+
/** @type {Map<string, { cwd: string, runs: number, wastedRuns: number, executorUsd: number, last: Map<string, { atMs: number, verdict: string }> }>} */
|
|
40
|
+
const byCwd = new Map();
|
|
41
|
+
const bucket = (/** @type {string} */ cwd) => {
|
|
42
|
+
let b = byCwd.get(cwd);
|
|
43
|
+
if (!b) {
|
|
44
|
+
b = { cwd, runs: 0, wastedRuns: 0, executorUsd: 0, last: new Map() };
|
|
45
|
+
byCwd.set(cwd, b);
|
|
46
|
+
}
|
|
47
|
+
return b;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const r of runs || []) {
|
|
51
|
+
if (!r || !inWindow(r.startedAtMs, fromMs, toMs)) continue;
|
|
52
|
+
const b = bucket(String(r.cwd));
|
|
53
|
+
b.runs += 1;
|
|
54
|
+
const v = r.verdict;
|
|
55
|
+
if (v != null && v !== 'clean' && !COMPLETED_EQUIVALENT_VERDICTS.has(v)) b.wastedRuns += 1;
|
|
56
|
+
const cost = Number(r.costUsd);
|
|
57
|
+
if (Number.isFinite(cost)) b.executorUsd += cost;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const v of validations || []) {
|
|
61
|
+
if (!v || !inWindow(v.atMs, fromMs, toMs)) continue;
|
|
62
|
+
const b = bucket(String(v.cwd));
|
|
63
|
+
const key = normSlug(v.slug);
|
|
64
|
+
const prev = b.last.get(key);
|
|
65
|
+
if (!prev || v.atMs >= prev.atMs) b.last.set(key, { atMs: v.atMs, verdict: v.verdict });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rows = [];
|
|
69
|
+
const totals = { runs: 0, wastedRuns: 0, verified: 0, refuted: 0, executorUsd: 0, wastedRunRate: 0 };
|
|
70
|
+
for (const b of byCwd.values()) {
|
|
71
|
+
let verified = 0;
|
|
72
|
+
let refuted = 0;
|
|
73
|
+
for (const { verdict } of b.last.values()) {
|
|
74
|
+
if (verdict === 'VERIFIED') verified += 1;
|
|
75
|
+
else if (verdict === 'REFUTED') refuted += 1;
|
|
76
|
+
}
|
|
77
|
+
rows.push({
|
|
78
|
+
cwd: b.cwd,
|
|
79
|
+
project: path.basename(b.cwd),
|
|
80
|
+
runs: b.runs,
|
|
81
|
+
wastedRuns: b.wastedRuns,
|
|
82
|
+
verified,
|
|
83
|
+
refuted,
|
|
84
|
+
executorUsd: b.executorUsd,
|
|
85
|
+
usdPerVerified: verified > 0 ? b.executorUsd / verified : null,
|
|
86
|
+
});
|
|
87
|
+
totals.runs += b.runs;
|
|
88
|
+
totals.wastedRuns += b.wastedRuns;
|
|
89
|
+
totals.verified += verified;
|
|
90
|
+
totals.refuted += refuted;
|
|
91
|
+
totals.executorUsd += b.executorUsd;
|
|
92
|
+
}
|
|
93
|
+
totals.wastedRunRate = totals.runs > 0 ? totals.wastedRuns / totals.runs : 0;
|
|
94
|
+
|
|
95
|
+
rows.sort((a, b) => b.executorUsd - a.executorUsd || (a.project < b.project ? -1 : a.project > b.project ? 1 : 0));
|
|
96
|
+
|
|
97
|
+
const pct = typeof weeklyUtilizationPct === 'number' ? weeklyUtilizationPct : NaN;
|
|
98
|
+
const hasPct = Number.isFinite(pct) && pct > 0;
|
|
99
|
+
return {
|
|
100
|
+
fromMs,
|
|
101
|
+
toMs,
|
|
102
|
+
rows,
|
|
103
|
+
totals,
|
|
104
|
+
weeklyUtilizationPct: weeklyUtilizationPct == null ? null : weeklyUtilizationPct,
|
|
105
|
+
verifiedPerUsagePct: hasPct ? totals.verified / pct : null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { computeLoopEfficiency };
|
|
@@ -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.
|