claude-code-session-manager 0.79.0 → 0.81.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-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/branchSweep.cjs +127 -0
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +117 -12
- package/src/main/lib/landedSinceRun.cjs +41 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +28 -5
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1554 -71
- package/src/preload/api.d.ts +8 -0
|
@@ -544,6 +544,69 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
544
544
|
return { ok: true, dir, branch, baseCwd: cwd, carriedPaths };
|
|
545
545
|
}
|
|
546
546
|
|
|
547
|
+
/**
|
|
548
|
+
* True iff every path in `paths` has working-tree content in `cwd`
|
|
549
|
+
* byte-identical to its committed blob on `branch`. Used both by the
|
|
550
|
+
* carried-wip-only shortcut above and by integrateBranch's auto-resolve of a
|
|
551
|
+
* "would be overwritten by merge" refusal below — in both cases discarding a
|
|
552
|
+
* working-tree copy is only safe BECAUSE identity with the incoming commit is
|
|
553
|
+
* proven, never assumed. Fails toward false (not identical) on ANY read
|
|
554
|
+
* error — a `git show` failure or an unreadable working-tree file is never
|
|
555
|
+
* proof of identity.
|
|
556
|
+
*/
|
|
557
|
+
async function pathsIdenticalToBranch({ cwd, branch, paths }) {
|
|
558
|
+
if (!Array.isArray(paths) || !paths.length) return false;
|
|
559
|
+
for (const p of paths) {
|
|
560
|
+
try {
|
|
561
|
+
const branchContent = await execGit(['show', `${branch}:${p}`], { cwd, timeout: 10_000 });
|
|
562
|
+
const baseContent = await fsp.readFile(path.join(cwd, p), 'utf8');
|
|
563
|
+
if (branchContent !== baseContent) return false;
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Parses the path lists out of git's refusal to merge over dirty working-tree
|
|
573
|
+
* files. Git emits up to two distinct blocks — tracked paths under "local
|
|
574
|
+
* changes ... would be overwritten" (restorable via `git checkout --`, which
|
|
575
|
+
* needs an index entry) and untracked paths under "untracked working tree
|
|
576
|
+
* files ... would be overwritten" (no index entry, so `git checkout --`
|
|
577
|
+
* cannot touch them — they must be removed instead). Each block is a run of
|
|
578
|
+
* TAB-indented path lines terminated by the next non-indented line (a
|
|
579
|
+
* "Please ..." sentence or the next block's own header). Returns null when
|
|
580
|
+
* neither block is present — stderr from a genuine content conflict has no
|
|
581
|
+
* such block.
|
|
582
|
+
*/
|
|
583
|
+
function parseBlockingMergePaths(stderrText) {
|
|
584
|
+
if (!stderrText) return null;
|
|
585
|
+
const tracked = [];
|
|
586
|
+
const untracked = [];
|
|
587
|
+
let mode = null;
|
|
588
|
+
for (const rawLine of stderrText.split('\n')) {
|
|
589
|
+
if (/would be overwritten by merge:\s*$/.test(rawLine) && /local changes/.test(rawLine)) {
|
|
590
|
+
mode = 'tracked';
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (/would be overwritten by merge:\s*$/.test(rawLine) && /untracked working tree files/.test(rawLine)) {
|
|
594
|
+
mode = 'untracked';
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (mode) {
|
|
598
|
+
if (/^\t/.test(rawLine)) {
|
|
599
|
+
const p = rawLine.trim();
|
|
600
|
+
if (p) (mode === 'tracked' ? tracked : untracked).push(p);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
mode = null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!tracked.length && !untracked.length) return null;
|
|
607
|
+
return { tracked: Array.from(new Set(tracked)), untracked: Array.from(new Set(untracked)) };
|
|
608
|
+
}
|
|
609
|
+
|
|
547
610
|
/**
|
|
548
611
|
* Integrate a branch back into `cwd`'s current HEAD — fast-forward when
|
|
549
612
|
* possible, a real merge commit when the main tree advanced underneath (a
|
|
@@ -600,17 +663,7 @@ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
|
600
663
|
// committed exactly the carried WIP and nothing more. Any mismatch
|
|
601
664
|
// (including a read failure — fail toward the safer default) falls
|
|
602
665
|
// through to the normal merge attempt below instead of skipping.
|
|
603
|
-
|
|
604
|
-
for (const p of changed) {
|
|
605
|
-
try {
|
|
606
|
-
const branchContent = await execGit(['show', `${branch}:${p}`], { cwd, timeout: 10_000 });
|
|
607
|
-
const baseContent = await fsp.readFile(path.join(cwd, p), 'utf8');
|
|
608
|
-
if (branchContent !== baseContent) { allIdentical = false; break; }
|
|
609
|
-
} catch {
|
|
610
|
-
allIdentical = false;
|
|
611
|
-
break;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
666
|
+
const allIdentical = await pathsIdenticalToBranch({ cwd, branch, paths: changed });
|
|
614
667
|
if (allIdentical) {
|
|
615
668
|
return { ok: true, integrated: false, reason: 'carried-wip-only' };
|
|
616
669
|
}
|
|
@@ -637,9 +690,40 @@ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
|
637
690
|
await execGit(['merge', '--no-ff', '--no-edit', '-m', mergeMessage, branch], { cwd, timeout: 30_000 });
|
|
638
691
|
return { ok: true, integrated: true, mergeCommit: true };
|
|
639
692
|
} catch (e) {
|
|
693
|
+
const stderrText = (e && (e.stderrText || e.message)) || String(e);
|
|
694
|
+
// git refuses this class of merge before ever touching MERGE_HEAD, so
|
|
695
|
+
// there is nothing yet to abort here — only the retry below (if it also
|
|
696
|
+
// fails) can leave a half-applied merge behind.
|
|
697
|
+
const blocking = parseBlockingMergePaths(stderrText);
|
|
698
|
+
if (blocking) {
|
|
699
|
+
const allPaths = [...blocking.tracked, ...blocking.untracked];
|
|
700
|
+
const identical = await pathsIdenticalToBranch({ cwd, branch, paths: allPaths });
|
|
701
|
+
if (identical) {
|
|
702
|
+
try {
|
|
703
|
+
if (blocking.tracked.length) {
|
|
704
|
+
await execGit(['checkout', '--', ...blocking.tracked], { cwd, timeout: 30_000 });
|
|
705
|
+
}
|
|
706
|
+
for (const p of blocking.untracked) {
|
|
707
|
+
await fsp.rm(path.join(cwd, p), { force: true });
|
|
708
|
+
}
|
|
709
|
+
await execGit(['merge', '--no-ff', '--no-edit', '-m', mergeMessage, branch], { cwd, timeout: 30_000 });
|
|
710
|
+
return {
|
|
711
|
+
ok: true,
|
|
712
|
+
integrated: true,
|
|
713
|
+
mergeCommit: true,
|
|
714
|
+
autoResolved: 'identical_working_tree_duplicates',
|
|
715
|
+
resolvedPaths: allPaths,
|
|
716
|
+
};
|
|
717
|
+
} catch (retryErr) {
|
|
718
|
+
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
719
|
+
const retryText = (retryErr && (retryErr.stderrText || retryErr.message)) || String(retryErr);
|
|
720
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${retryText}` };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
640
724
|
// Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
|
|
641
725
|
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
642
|
-
return { ok: false, reason: `merge failed (likely a real content conflict): ${
|
|
726
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}` };
|
|
643
727
|
}
|
|
644
728
|
}
|
|
645
729
|
|
|
@@ -775,6 +859,25 @@ async function salvageDirtyDelta({ cwd, paths, outFile }) {
|
|
|
775
859
|
}
|
|
776
860
|
}
|
|
777
861
|
|
|
862
|
+
/**
|
|
863
|
+
* True when every commit on `branch` is already an ancestor of (or equal to)
|
|
864
|
+
* `cwd`'s current HEAD — i.e. the branch holds no work that isn't already
|
|
865
|
+
* landed. Used by the stranded-branch sweep (branchSweep.cjs) to tell a
|
|
866
|
+
* genuinely stranded `sm-job/*` branch from one that's already a no-op.
|
|
867
|
+
* Never throws; a branch that doesn't exist, or any other git failure, is
|
|
868
|
+
* conservatively reported as NOT merged so a caller never treats an
|
|
869
|
+
* unreadable branch as safe to skip.
|
|
870
|
+
*/
|
|
871
|
+
async function isBranchMergedIntoHead(cwd, branch) {
|
|
872
|
+
try {
|
|
873
|
+
const branchHead = (await execGit(['rev-parse', '--verify', branch], { cwd, timeout: 10_000 })).trim();
|
|
874
|
+
const mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
|
|
875
|
+
return mergeBase !== '' && mergeBase === branchHead;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
778
881
|
/** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
|
|
779
882
|
function parseWorktreeListPorcelain(text) {
|
|
780
883
|
const entries = [];
|
|
@@ -914,10 +1017,12 @@ module.exports = {
|
|
|
914
1017
|
keyFromBranch,
|
|
915
1018
|
createWorktree,
|
|
916
1019
|
captureAndCarryBaseDiff,
|
|
1020
|
+
pathsIdenticalToBranch,
|
|
917
1021
|
integrateBranch,
|
|
918
1022
|
cleanupWorktree,
|
|
919
1023
|
salvageWorktreeDiff,
|
|
920
1024
|
salvageDirtyDelta,
|
|
1025
|
+
isBranchMergedIntoHead,
|
|
921
1026
|
parseWorktreeListPorcelain,
|
|
922
1027
|
reconcileWorktreesOnBoot,
|
|
923
1028
|
sweepStaleWorktreeCheckouts,
|
|
@@ -52,4 +52,44 @@ function landedSinceRun(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIM
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Commits reachable from `main` in `cwd` since `sinceIso` that touch any of
|
|
57
|
+
* `paths` — the evidence query behind PRD 1136's already-satisfied-on-main
|
|
58
|
+
* commit-guard exemption. Deliberately narrower than landedSinceRun in one
|
|
59
|
+
* axis (scoped to `main`, not `--all` — a commit on a stray branch is not
|
|
60
|
+
* "satisfied") and identical in the other (path-scoped, so an unrelated
|
|
61
|
+
* commit elsewhere in the repo is never credited to this job).
|
|
62
|
+
*
|
|
63
|
+
* Falls back from local `main` to `origin/main` when the local branch ref
|
|
64
|
+
* doesn't exist (a worktree/CI checkout that never checked main out
|
|
65
|
+
* locally) — never throws either way; git-unavailable, a non-repo cwd, an
|
|
66
|
+
* unresolvable ref, or an empty `paths` list all resolve to `[]`.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @param {string} sinceIso
|
|
70
|
+
* @param {string[]} paths
|
|
71
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
72
|
+
* @returns {Promise<string[]>} full commit SHAs, newest first
|
|
73
|
+
*/
|
|
74
|
+
function landedOnMainSince(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIMEOUT_MS } = {}) {
|
|
75
|
+
const runLog = (ref) => new Promise((resolve) => {
|
|
76
|
+
execFile(
|
|
77
|
+
'git',
|
|
78
|
+
['-C', cwd, 'log', ref, `--since=${sinceIso}`, '--format=%H', '--', ...paths],
|
|
79
|
+
{ timeout: timeoutMs, windowsHide: true },
|
|
80
|
+
(err, stdout) => {
|
|
81
|
+
if (err) { resolve(null); return; }
|
|
82
|
+
resolve(String(stdout || '').trim().split('\n').filter(Boolean));
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
return (async () => {
|
|
87
|
+
if (!cwd || !sinceIso || !Array.isArray(paths) || paths.length === 0) return [];
|
|
88
|
+
const viaLocal = await runLog('main');
|
|
89
|
+
if (viaLocal !== null) return viaLocal;
|
|
90
|
+
const viaRemote = await runLog('origin/main');
|
|
91
|
+
return viaRemote ?? [];
|
|
92
|
+
})();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { landedSinceRun, landedOnMainSince, LANDED_SINCE_RUN_TIMEOUT_MS };
|
|
@@ -133,7 +133,10 @@ const MCP_TOOL_CATALOG = [
|
|
|
133
133
|
+ 'app\'s admin API. Refuses once a queue row exists for the slug and its status is anything but "pending" '
|
|
134
134
|
+ '(running/completed/failed/needs_review) — editing the spec under a live or already-finished executor is refused, '
|
|
135
135
|
+ 'not silently applied. Only recognized frontmatter keys (title, cwd, estimateMinutes, parallelGroup, '
|
|
136
|
-
+ 'sourcePromptId, sourceTabId, tag) may be patched; unrecognized keys
|
|
136
|
+
+ 'sourcePromptId, sourceTabId, tag, dependsOn) may be patched; unrecognized keys round-trip unchanged. '
|
|
137
|
+
+ 'Patching dependsOn to a non-empty array replaces it wholesale (validated against existing PRD slugs, '
|
|
138
|
+
+ 'same resolver scheduler_create_prd uses); patching it to an explicit empty array CLEARS the dependency — '
|
|
139
|
+
+ 'the safe way to fix a wrong dependsOn without archiving (which marks the PRD completed and wrongly frees its dependents).',
|
|
137
140
|
whenToUse: 'Use to correct a PRD scope/estimate/tag before it starts running — e.g. before resetting a needs_review job whose spec needs to change.',
|
|
138
141
|
whenNotToUse: 'Do not use once the job is running or terminal (completed/failed/needs_review) without first resetting it back to pending — the route refuses the edit.',
|
|
139
142
|
exampleArgs: { slug: 'add-mcp-tool-catalog', frontmatter: { estimateMinutes: 45 } },
|
|
@@ -28,6 +28,17 @@ const { appendAuditEvent } = require('./auditLog.cjs');
|
|
|
28
28
|
const { resolveProjectContext } = require('./projectRootResolve.cjs');
|
|
29
29
|
const { fixChainDepthOf, baseSlugOf } = require('./fixChainDepth.cjs');
|
|
30
30
|
const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
|
|
31
|
+
const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
|
|
32
|
+
const { isFixPlanSlug } = require('./fixPlanSlug.cjs');
|
|
33
|
+
|
|
34
|
+
// A caller-supplied slug that already starts with its own `NN-` (e.g.
|
|
35
|
+
// "254-perf-x") used to silently become the double-prefixed row
|
|
36
|
+
// "254-254-perf-x" once allocateParallelGroup() prepended the REAL
|
|
37
|
+
// allocated NN — PRD_CREATE_SLUG_RE only enforces kebab-case shape, so it
|
|
38
|
+
// never caught this. Refused outright rather than auto-stripped: silently
|
|
39
|
+
// rewriting the caller's slug would hide the same mistake the old behavior
|
|
40
|
+
// hid, just one layer further down.
|
|
41
|
+
const SLUG_HAS_NN_PREFIX_RE = /^(\d+)-/;
|
|
31
42
|
|
|
32
43
|
// Fix-chain depth cap (PRD 1113): a fix-of-a-fix (depth >= 2) is refused at
|
|
33
44
|
// this shared write path rather than caught later — scheduler.cjs's
|
|
@@ -157,7 +168,7 @@ function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
|
|
|
157
168
|
* the renderer-facing IPC handler (chat:create-prd, index.cjs) so the
|
|
158
169
|
* validation/write logic lives in exactly one place (API-reuse standard).
|
|
159
170
|
* `input` must already be schema-validated by the caller (schemas.schedulerCreatePrd).
|
|
160
|
-
* `remote` is scheduler.cjs's remote object (allocateParallelGroup/readPrd/writePrd).
|
|
171
|
+
* `remote` is scheduler.cjs's remote object (allocateParallelGroup/readPrd/writePrd/listPrds).
|
|
161
172
|
*
|
|
162
173
|
* Returns `{ ok: true, nn, filename }` on success, or `{ ok: false, status, error }`
|
|
163
174
|
* on failure — `status` is the HTTP status code the caller should map errors to
|
|
@@ -226,6 +237,18 @@ async function createPrd(input, remote) {
|
|
|
226
237
|
if (fallback) input = { ...input, sourcePromptId: fallback };
|
|
227
238
|
}
|
|
228
239
|
|
|
240
|
+
if (input.slug) {
|
|
241
|
+
const nnMatch = SLUG_HAS_NN_PREFIX_RE.exec(input.slug);
|
|
242
|
+
if (nnMatch) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
status: 400,
|
|
246
|
+
error: `slug "${input.slug}" already starts with a numeric prefix "${nnMatch[1]}-" — the allocator ` +
|
|
247
|
+
`owns the NN- prefix, not the caller. Pass the bare name instead (e.g. "${input.slug.slice(nnMatch[0].length)}").`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
229
252
|
const slug = input.slug || deriveSlugFromTitle(input.title);
|
|
230
253
|
if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
|
|
231
254
|
return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
|
|
@@ -243,10 +266,10 @@ async function createPrd(input, remote) {
|
|
|
243
266
|
const filenameSlug = `${nn}-${slug}`;
|
|
244
267
|
|
|
245
268
|
// Fix-chain depth guard (PRD 1113): refuse a fix-of-a-fix before it's ever
|
|
246
|
-
// written. depth
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
269
|
+
// written. depth >= 2 means the base job has already failed to close via
|
|
270
|
+
// at least one prior fix attempt for reasons a repeat attempt won't
|
|
271
|
+
// resolve — checked first so this distinct, human-escalation error still
|
|
272
|
+
// wins over the more general fix-plan-slug guard below for that shape.
|
|
250
273
|
const chainDepth = fixChainDepthOf(filenameSlug);
|
|
251
274
|
if (chainDepth > FIX_CHAIN_DEPTH_CAP) {
|
|
252
275
|
const base = baseSlugOf(filenameSlug);
|
|
@@ -259,6 +282,29 @@ async function createPrd(input, remote) {
|
|
|
259
282
|
};
|
|
260
283
|
}
|
|
261
284
|
|
|
285
|
+
// Fix-plan-slug collision guard (PRD 1131): a caller-supplied or
|
|
286
|
+
// title-derived slug that happens to start with "fix-" collides with the
|
|
287
|
+
// scheduler's own NN-fix-... naming convention for fix plans it authors
|
|
288
|
+
// itself (spawnInvestigation) — and that shape carries real special-case
|
|
289
|
+
// semantics (depth-capped auto-investigation eligibility,
|
|
290
|
+
// commitGuardVerdict's zero-edit exemption) that a PRD authored through
|
|
291
|
+
// this API never asked for. Refused outright, not silently renamed, same
|
|
292
|
+
// fail-closed posture as the SLUG_HAS_NN_PREFIX_RE check above (PRD 1126:
|
|
293
|
+
// "126-fix-plan-death-reopens-parent" was wrongly stamped
|
|
294
|
+
// investigationDepth before it ever ran and had to be withdrawn). This
|
|
295
|
+
// subsumes the depth-1 case the chain-depth guard above deliberately let
|
|
296
|
+
// through (MAX_INVESTIGATION_DEPTH=1 means a first fix-plan is legitimate
|
|
297
|
+
// — but only when spawnInvestigation authors it, never through this API).
|
|
298
|
+
if (isFixPlanSlug(filenameSlug)) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
status: 400,
|
|
302
|
+
error: `slug "${filenameSlug}" collides with the reserved NN-fix-... fix-plan naming convention ` +
|
|
303
|
+
'(reserved for PRDs the scheduler\'s own investigation loop authors itself). Reword the title/slug ' +
|
|
304
|
+
'so it does not start with "fix-" — e.g. "resolve-x" or "repair-x" instead of "fix-x".',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
262
308
|
// An explicit `parallelGroup` bypasses allocateParallelGroup()'s
|
|
263
309
|
// collision-proof reservation, so re-check for an existing file at
|
|
264
310
|
// this exact destination before writing — remote.writePrd itself has
|
|
@@ -284,6 +330,39 @@ async function createPrd(input, remote) {
|
|
|
284
330
|
}
|
|
285
331
|
}
|
|
286
332
|
|
|
333
|
+
// Write-time FK check for dependsOn (report on read / throw on write — see
|
|
334
|
+
// prdAgentType.cjs's header): every entry must resolve to a PRD that
|
|
335
|
+
// actually exists in this project, using the SAME resolution rule
|
|
336
|
+
// schedulerBatch.cjs's findBlockingDep applies at run time (exact slug,
|
|
337
|
+
// else bare-name after stripping one leading `NN-`) — a dep that
|
|
338
|
+
// validates here is guaranteed to be a dep findBlockingDep can actually
|
|
339
|
+
// see later. A listPrds() read failure is skipped-with-a-warning, never a
|
|
340
|
+
// write outage: an I/O hiccup on the read side must not block every PRD
|
|
341
|
+
// write in the project.
|
|
342
|
+
if (input.dependsOn && input.dependsOn.length) {
|
|
343
|
+
let listing;
|
|
344
|
+
try {
|
|
345
|
+
listing = await remote.listPrds({ cwd: input.cwd, limit: Number.MAX_SAFE_INTEGER });
|
|
346
|
+
} catch (e) {
|
|
347
|
+
console.warn(`[prdCreate] dependsOn validation skipped (listPrds failed): ${e?.message ?? e}`);
|
|
348
|
+
listing = null;
|
|
349
|
+
}
|
|
350
|
+
if (listing) {
|
|
351
|
+
const candidateSlugs = (listing.prds ?? []).map((p) => p.slug);
|
|
352
|
+
for (const dep of input.dependsOn) {
|
|
353
|
+
if (resolveDepSlug(dep, candidateSlugs).length > 0) continue;
|
|
354
|
+
const near = findNearMatches(dep, candidateSlugs);
|
|
355
|
+
const suggestion = near.length ? ` Closest existing slug(s): ${near.join(', ')}.` : '';
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
status: 400,
|
|
359
|
+
error: `dependsOn entry "${dep}" does not resolve to any existing PRD in this project.${suggestion} ` +
|
|
360
|
+
'Pass the bare name (preferred) or the exact NN-prefixed slug of an existing PRD.',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
287
366
|
const body = buildPrdBody(input);
|
|
288
367
|
const writeResult = await remote.writePrd(filenameSlug, body, input.cwd);
|
|
289
368
|
if (!writeResult?.ok) {
|
|
@@ -62,7 +62,7 @@ function splitFrontmatter(raw) {
|
|
|
62
62
|
* either surface never reorders or drops a key it didn't touch. Recognized
|
|
63
63
|
* key set intentionally mirrors the TS module exactly (title, cwd,
|
|
64
64
|
* estimateMinutes, parallelGroup, sourcePromptId, sourceTabId, tag,
|
|
65
|
-
* agentType, createdVia, issuedAt); every other frontmatter key
|
|
65
|
+
* agentType, createdVia, issuedAt, dependsOn); every other frontmatter key
|
|
66
66
|
* round-trips via `extras`, same as the renderer's editor today.
|
|
67
67
|
*
|
|
68
68
|
* `createdVia`/`issuedAt` (PRD provenance-lockdown) are recognized here so
|
|
@@ -70,10 +70,15 @@ function splitFrontmatter(raw) {
|
|
|
70
70
|
* after creation — see prdCreate.cjs's buildPrdBody for the create-time
|
|
71
71
|
* stamp) round-trips them like any other known key rather than shunting them
|
|
72
72
|
* into `extras`.
|
|
73
|
+
*
|
|
74
|
+
* `dependsOn` (PRD 1124) is the first ARRAY-valued recognized key — parsed/
|
|
75
|
+
* emitted only in its inline `[a, b]` list form (the sole form prdCreate.cjs
|
|
76
|
+
* ever writes; block-style YAML lists are out of scope). An empty array is
|
|
77
|
+
* never emitted, which is how `scheduler_update_prd` clears a dependency.
|
|
73
78
|
*/
|
|
74
79
|
|
|
75
|
-
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'quietMachine']);
|
|
76
|
-
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'quietMachine'];
|
|
80
|
+
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine']);
|
|
81
|
+
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine'];
|
|
77
82
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
78
83
|
|
|
79
84
|
function indentOf(line) {
|
|
@@ -99,6 +104,28 @@ function parseScalar(raw) {
|
|
|
99
104
|
return v;
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Parse the inline `[a, b]` list form used by `dependsOn` — the sole form
|
|
109
|
+
* prdCreate.cjs's buildPrdBody ever writes. Returns null (not recognized as
|
|
110
|
+
* a list) when `raw` isn't bracketed, so a hand-edited non-list value is
|
|
111
|
+
* left unset rather than mis-parsed.
|
|
112
|
+
*/
|
|
113
|
+
function parseInlineList(raw) {
|
|
114
|
+
const v = raw.trim();
|
|
115
|
+
if (!v.startsWith('[') || !v.endsWith(']')) return null;
|
|
116
|
+
const inner = v.slice(1, -1).trim();
|
|
117
|
+
if (inner === '') return [];
|
|
118
|
+
return inner
|
|
119
|
+
.split(',')
|
|
120
|
+
.map((s) => s.trim().replace(/^(['"])(.*)\1$/, '$2'))
|
|
121
|
+
.filter((s) => s.length > 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function arraysEqual(a, b) {
|
|
125
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
126
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
function applyKey(fm, key, after) {
|
|
103
130
|
const v = parseScalar(after);
|
|
104
131
|
if (v === null) return;
|
|
@@ -138,6 +165,13 @@ function applyKey(fm, key, after) {
|
|
|
138
165
|
case 'issuedAt':
|
|
139
166
|
fm.issuedAt = String(v);
|
|
140
167
|
return;
|
|
168
|
+
case 'dependsOn': {
|
|
169
|
+
// Only the inline `[a, b]` list form is recognized (see parseInlineList's
|
|
170
|
+
// header) — a malformed/hand-edited non-list value is left unset.
|
|
171
|
+
const list = parseInlineList(after);
|
|
172
|
+
if (list) fm.dependsOn = list;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
141
175
|
case 'quietMachine':
|
|
142
176
|
// Opt-in exclusive-lease flag (PRD 1107) — only a literal `true`
|
|
143
177
|
// frontmatter value opts in; anything else (including an explicit
|
|
@@ -180,10 +214,17 @@ function parsePrdFile(text) {
|
|
|
180
214
|
|
|
181
215
|
if (RECOGNIZED_KEYS.has(key)) {
|
|
182
216
|
applyKey(fm, key, after);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
217
|
+
if (key === 'dependsOn') {
|
|
218
|
+
if (fm.dependsOn) {
|
|
219
|
+
if (!fm._raw) fm._raw = {};
|
|
220
|
+
fm._raw[key] = { line, parsed: fm.dependsOn };
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
const parsed = parseScalar(after);
|
|
224
|
+
if (parsed !== null && (typeof parsed === 'string' || typeof parsed === 'number')) {
|
|
225
|
+
if (!fm._raw) fm._raw = {};
|
|
226
|
+
fm._raw[key] = { line, parsed };
|
|
227
|
+
}
|
|
187
228
|
}
|
|
188
229
|
} else {
|
|
189
230
|
extras[key] = { lines: band };
|
|
@@ -218,8 +259,11 @@ function serializePrdFile(fm, body) {
|
|
|
218
259
|
for (const key of EMIT_ORDER) {
|
|
219
260
|
const v = fm[key];
|
|
220
261
|
if (v === undefined || v === null || v === '') continue;
|
|
262
|
+
// An empty dependsOn array is how scheduler_update_prd CLEARS the
|
|
263
|
+
// dependency — never emitted, same as an absent key.
|
|
264
|
+
if (Array.isArray(v) && v.length === 0) continue;
|
|
221
265
|
const cached = fm._raw?.[key];
|
|
222
|
-
if (cached && cached.parsed === v) {
|
|
266
|
+
if (cached && (Array.isArray(v) ? arraysEqual(cached.parsed, v) : cached.parsed === v)) {
|
|
223
267
|
out.push(cached.line);
|
|
224
268
|
continue;
|
|
225
269
|
}
|
|
@@ -227,6 +271,10 @@ function serializePrdFile(fm, body) {
|
|
|
227
271
|
out.push(`${key}: ${v}`);
|
|
228
272
|
continue;
|
|
229
273
|
}
|
|
274
|
+
if (Array.isArray(v)) {
|
|
275
|
+
out.push(`${key}: [${v.join(', ')}]`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
230
278
|
out.push(`${key}: ${serializeScalar(v)}`);
|
|
231
279
|
}
|
|
232
280
|
if (fm.extras) {
|
|
@@ -15,6 +15,7 @@ const path = require('path');
|
|
|
15
15
|
const fsp = require('fs').promises;
|
|
16
16
|
const { HISTORY_RETENTION_MS } = require('./schedulerConfig.cjs');
|
|
17
17
|
const { projectHistoryPath, stateCwds } = require('./queueStore.cjs');
|
|
18
|
+
const { resolveIsFixPlan } = require('./fixPlanSlug.cjs');
|
|
18
19
|
|
|
19
20
|
// Legacy global sidecar — READ-ONLY since 2026-07-31 (federated per-project
|
|
20
21
|
// history under <cwd>/session-manager-operations/scheduler/state/history.jsonl,
|
|
@@ -52,10 +53,6 @@ function historyPathFor(entry) {
|
|
|
52
53
|
return HISTORY_PATH;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function isFixPlanSlug(slug) {
|
|
56
|
-
return /^\d+-fix-/.test(slug);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
56
|
function jobKey(job) {
|
|
60
57
|
return `${job?.slug ?? ''}|${job?.runId ?? ''}`;
|
|
61
58
|
}
|
|
@@ -79,9 +76,13 @@ function partitionJobs(jobs, nowMs, opts = {}) {
|
|
|
79
76
|
|
|
80
77
|
// Slugs of jobs that a still-pending/running fix-plan is going to try to
|
|
81
78
|
// heal (see healTargetForFix in scheduler.cjs — same regex, kept in sync).
|
|
79
|
+
// Honors the job's own persisted `isFixPlan` provenance stamp (PRD 1131)
|
|
80
|
+
// over the slug shape, same as every other fix-plan consumer in
|
|
81
|
+
// scheduler.cjs — a slug that merely starts with "fix-" but was classified
|
|
82
|
+
// isFixPlan:false must not get treated as a real fix-plan here either.
|
|
82
83
|
const protectedSlugs = new Set();
|
|
83
84
|
for (const j of list) {
|
|
84
|
-
if (!j || !
|
|
85
|
+
if (!j || !resolveIsFixPlan(j.slug, j.isFixPlan)) continue;
|
|
85
86
|
if (j.status !== 'pending' && j.status !== 'running') continue;
|
|
86
87
|
protectedSlugs.add(j.slug.replace(/^(\d+)-fix-/, '$1-'));
|
|
87
88
|
}
|
|
@@ -269,10 +270,54 @@ async function historyTerminalBySlug() {
|
|
|
269
270
|
return map;
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
/**
|
|
274
|
+
* completedSlugsForCwd(cwd) → Set<string> of every slug whose most recent
|
|
275
|
+
* appended record in THIS project's own history shard
|
|
276
|
+
* (`<cwd>/session-manager-operations/scheduler/state/history.jsonl`, via
|
|
277
|
+
* projectHistoryPath) has status 'completed'. Scoped to one project's shard
|
|
278
|
+
* — unlike historyTerminalBySlug's federated, machine-wide map — because
|
|
279
|
+
* this feeds PRD 1122's dependsOn history lookup, where a same-named PRD in
|
|
280
|
+
* an unrelated project must never count as satisfying a dep. Missing file
|
|
281
|
+
* (ENOENT) returns an empty Set, not an error — a project with no history
|
|
282
|
+
* yet is not a read failure.
|
|
283
|
+
*/
|
|
284
|
+
async function completedSlugsForCwd(cwd) {
|
|
285
|
+
let text = '';
|
|
286
|
+
try {
|
|
287
|
+
text = await fsp.readFile(projectHistoryPath(cwd), 'utf8');
|
|
288
|
+
} catch (e) {
|
|
289
|
+
if (e.code === 'ENOENT') return new Set();
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
292
|
+
// Last-line-wins per slug (file is append-only chronological) — same
|
|
293
|
+
// pattern as historyTerminalBySlug above. A slug can legitimately appear
|
|
294
|
+
// more than once (scheduler_reset_job resets a completed row back to
|
|
295
|
+
// pending, it runs again, and eventually re-retires to history) — unioning
|
|
296
|
+
// every 'completed' line ever seen, without letting a LATER 'failed' line
|
|
297
|
+
// for the same slug revoke it, would keep a since-refailed dep permanently
|
|
298
|
+
// satisfied for its dependents.
|
|
299
|
+
const statusBySlug = new Map();
|
|
300
|
+
for (const line of text.split('\n')) {
|
|
301
|
+
if (!line.trim()) continue;
|
|
302
|
+
try {
|
|
303
|
+
const j = JSON.parse(line);
|
|
304
|
+
if (j?.slug) statusBySlug.set(j.slug, j.status);
|
|
305
|
+
} catch {
|
|
306
|
+
// corrupt/partial line — ignore
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const slugs = new Set();
|
|
310
|
+
for (const [slug, status] of statusBySlug) {
|
|
311
|
+
if (status === 'completed') slugs.add(slug);
|
|
312
|
+
}
|
|
313
|
+
return slugs;
|
|
314
|
+
}
|
|
315
|
+
|
|
272
316
|
module.exports = {
|
|
273
317
|
HISTORY_PATH,
|
|
274
318
|
partitionJobs,
|
|
275
319
|
appendHistory,
|
|
276
320
|
readHistory,
|
|
277
321
|
historyTerminalBySlug,
|
|
322
|
+
completedSlugsForCwd,
|
|
278
323
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rateLimitWindow.cjs — reads the BINDING rate-limit window's reset time
|
|
5
|
+
* straight off a run's own log, instead of trusting the billing usage
|
|
6
|
+
* endpoint's five_hour window (PRD 1118). A run can be 429'd by a
|
|
7
|
+
* different window than five_hour (seven_day, seven_day_overage_included)
|
|
8
|
+
* while five_hour utilization is still 0 — pausing against five_hour's
|
|
9
|
+
* reset in that case arms the resume timer against the wrong clock, and
|
|
10
|
+
* the billing endpoint can itself be 429'ing when this is needed most.
|
|
11
|
+
*
|
|
12
|
+
* Pure: log path in, reset value out. No electron import, so it is
|
|
13
|
+
* unit-testable without scheduler.cjs (mirrors reaperHelpers.cjs's split).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { readTail } = require('./fileTail.cjs');
|
|
17
|
+
|
|
18
|
+
// Authoritative window block rides on the LAST rate_limit_event in the log
|
|
19
|
+
// (often the one attached to the final result), so mirror classifyRunOutcome's
|
|
20
|
+
// 64 KB tail rather than detectRateLimitInLog's smaller 16 KB one.
|
|
21
|
+
const TAIL_BYTES = 64 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scans a run log's tail for rate_limit_event entries and returns the
|
|
25
|
+
* UNIX-seconds resetsAt of the window that actually bound (utilization >= 1.0)
|
|
26
|
+
* in the LAST such event. When multiple windows bind in that event, the
|
|
27
|
+
* latest resetsAt wins. Returns null when no rate_limit_event is found, or
|
|
28
|
+
* none of its windows are at/above 1.0 utilization.
|
|
29
|
+
*/
|
|
30
|
+
function resolveBindingRateLimitReset(logPath) {
|
|
31
|
+
const text = readTail(logPath, TAIL_BYTES);
|
|
32
|
+
if (!text) return null;
|
|
33
|
+
|
|
34
|
+
let lastWindows = null;
|
|
35
|
+
for (const line of text.split('\n')) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed || trimmed[0] !== '{') continue;
|
|
38
|
+
let event;
|
|
39
|
+
try {
|
|
40
|
+
event = JSON.parse(trimmed);
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const windows = event?.rate_limit_info?.unifiedWindows;
|
|
45
|
+
if (event?.type === 'rate_limit_event' && windows && typeof windows === 'object') {
|
|
46
|
+
lastWindows = windows;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!lastWindows) return null;
|
|
50
|
+
|
|
51
|
+
let latestBoundReset = null;
|
|
52
|
+
for (const window of Object.values(lastWindows)) {
|
|
53
|
+
if (!window || typeof window.utilization !== 'number' || typeof window.resetsAt !== 'number') continue;
|
|
54
|
+
if (window.utilization < 1.0) continue;
|
|
55
|
+
if (latestBoundReset === null || window.resetsAt > latestBoundReset) {
|
|
56
|
+
latestBoundReset = window.resetsAt;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return latestBoundReset;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { resolveBindingRateLimitReset };
|