@in-the-loop-labs/pair-review 3.2.2 → 3.3.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/README.md +7 -6
- package/package.json +5 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +8 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +8 -7
- package/public/css/repo-settings.css +347 -0
- package/public/index.html +46 -9
- package/public/js/components/AIPanel.js +79 -37
- package/public/js/components/DiffOptionsDropdown.js +84 -1
- package/public/js/index.js +31 -6
- package/public/js/modules/analysis-history.js +11 -7
- package/public/js/pr.js +22 -0
- package/public/js/repo-settings.js +334 -6
- package/public/repo-settings.html +29 -0
- package/src/ai/analyzer.js +28 -19
- package/src/ai/claude-cli.js +2 -0
- package/src/ai/claude-provider.js +4 -1
- package/src/ai/prompts/baseline/consolidation/balanced.js +6 -4
- package/src/ai/prompts/baseline/consolidation/fast.js +6 -2
- package/src/ai/prompts/baseline/consolidation/thorough.js +7 -6
- package/src/ai/prompts/baseline/orchestration/balanced.js +13 -1
- package/src/ai/prompts/baseline/orchestration/fast.js +12 -1
- package/src/ai/prompts/baseline/orchestration/thorough.js +8 -7
- package/src/ai/provider.js +7 -6
- package/src/chat/session-manager.js +6 -3
- package/src/config.js +230 -38
- package/src/database.js +766 -38
- package/src/git/worktree-pool-lifecycle.js +674 -0
- package/src/git/worktree-pool-usage.js +216 -0
- package/src/git/worktree.js +46 -13
- package/src/main.js +185 -26
- package/src/routes/analyses.js +48 -26
- package/src/routes/chat.js +27 -3
- package/src/routes/config.js +17 -5
- package/src/routes/executable-analysis.js +38 -19
- package/src/routes/local.js +19 -6
- package/src/routes/mcp.js +13 -2
- package/src/routes/pr.js +72 -29
- package/src/routes/setup.js +41 -4
- package/src/routes/stack-analysis.js +29 -10
- package/src/routes/worktrees.js +294 -9
- package/src/server.js +20 -3
- package/src/setup/pr-setup.js +161 -27
- package/src/ws/server.js +51 -1
package/src/main.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName,
|
|
4
|
-
const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository } = require('./database');
|
|
3
|
+
const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
|
|
4
|
+
const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository } = require('./database');
|
|
5
5
|
const { PRArgumentParser } = require('./github/parser');
|
|
6
6
|
const { GitHubClient } = require('./github/client');
|
|
7
7
|
const { GitWorktreeManager } = require('./git/worktree');
|
|
8
|
+
const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
|
|
8
9
|
const { startServer } = require('./server');
|
|
9
10
|
const Analyzer = require('./ai/analyzer');
|
|
10
11
|
const { applyConfigOverrides } = require('./ai');
|
|
@@ -445,6 +446,10 @@ AI PROVIDERS:
|
|
|
445
446
|
console.warn('Some worktrees could not be migrated:', migrationResult.errors);
|
|
446
447
|
}
|
|
447
448
|
|
|
449
|
+
// Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
|
|
450
|
+
const poolLifecycle = new WorktreePoolLifecycle(db, config);
|
|
451
|
+
await poolLifecycle.resetAndRehydrate();
|
|
452
|
+
|
|
448
453
|
// Parse command line arguments including flags
|
|
449
454
|
const { prArgs, flags } = parseArgs(args);
|
|
450
455
|
|
|
@@ -477,8 +482,9 @@ AI PROVIDERS:
|
|
|
477
482
|
// Async cleanup of stale worktrees and reviews (don't block startup)
|
|
478
483
|
cleanupStaleWorktreesAsync(config);
|
|
479
484
|
cleanupStaleReviewsAsync(config);
|
|
485
|
+
startPoolBackgroundFetches(db, config);
|
|
480
486
|
|
|
481
|
-
return;
|
|
487
|
+
return;
|
|
482
488
|
}
|
|
483
489
|
|
|
484
490
|
// Auto-detect GitHub Actions environment
|
|
@@ -510,11 +516,11 @@ AI PROVIDERS:
|
|
|
510
516
|
|
|
511
517
|
// Check for --ai-review mode (takes precedence over --ai-draft and --ai)
|
|
512
518
|
if (flags.aiReview) {
|
|
513
|
-
await handleActionReview(effectivePrArgs, config, db, flags);
|
|
519
|
+
await handleActionReview(effectivePrArgs, config, db, flags, poolLifecycle);
|
|
514
520
|
} else if (flags.aiDraft) {
|
|
515
|
-
await handleDraftModeReview(effectivePrArgs, config, db, flags);
|
|
521
|
+
await handleDraftModeReview(effectivePrArgs, config, db, flags, poolLifecycle);
|
|
516
522
|
} else {
|
|
517
|
-
await handlePullRequest(effectivePrArgs, config, db, flags);
|
|
523
|
+
await handlePullRequest(effectivePrArgs, config, db, flags, poolLifecycle);
|
|
518
524
|
}
|
|
519
525
|
} else {
|
|
520
526
|
// Check if --ai or --ai-draft flags were used without PR identifier
|
|
@@ -530,7 +536,7 @@ AI PROVIDERS:
|
|
|
530
536
|
|
|
531
537
|
// No PR arguments - just start the server
|
|
532
538
|
console.log('No pull request specified. Starting server...');
|
|
533
|
-
await startServerOnly(config);
|
|
539
|
+
await startServerOnly(config, poolLifecycle);
|
|
534
540
|
}
|
|
535
541
|
|
|
536
542
|
} catch (error) {
|
|
@@ -545,8 +551,9 @@ AI PROVIDERS:
|
|
|
545
551
|
* @param {Object} config - Application configuration
|
|
546
552
|
* @param {Object} db - Database instance
|
|
547
553
|
* @param {Object} flags - Parsed command line flags
|
|
554
|
+
* @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Pool lifecycle instance
|
|
548
555
|
*/
|
|
549
|
-
async function handlePullRequest(args, config, db, flags = {}) {
|
|
556
|
+
async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = null) {
|
|
550
557
|
try {
|
|
551
558
|
// Get GitHub token (env var takes precedence over config)
|
|
552
559
|
const githubToken = getGitHubToken(config);
|
|
@@ -571,11 +578,12 @@ async function handlePullRequest(args, config, db, flags = {}) {
|
|
|
571
578
|
}
|
|
572
579
|
|
|
573
580
|
// Start server and open browser to setup page
|
|
574
|
-
const port = await startServer(db);
|
|
581
|
+
const port = await startServer(db, poolLifecycle);
|
|
575
582
|
|
|
576
583
|
// Async cleanup of stale worktrees and reviews (don't block startup)
|
|
577
584
|
cleanupStaleWorktreesAsync(config);
|
|
578
585
|
cleanupStaleReviewsAsync(config);
|
|
586
|
+
startPoolBackgroundFetches(db, config);
|
|
579
587
|
|
|
580
588
|
let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
|
|
581
589
|
if (flags.ai) {
|
|
@@ -594,13 +602,15 @@ async function handlePullRequest(args, config, db, flags = {}) {
|
|
|
594
602
|
/**
|
|
595
603
|
* Start server without PR context
|
|
596
604
|
* @param {Object} config - Application configuration
|
|
605
|
+
* @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Pool lifecycle instance
|
|
597
606
|
*/
|
|
598
|
-
async function startServerOnly(config) {
|
|
599
|
-
const port = await startServer(db);
|
|
607
|
+
async function startServerOnly(config, poolLifecycle = null) {
|
|
608
|
+
const port = await startServer(db, poolLifecycle);
|
|
600
609
|
|
|
601
610
|
// Async cleanup of stale worktrees and reviews (don't block startup)
|
|
602
611
|
cleanupStaleWorktreesAsync(config);
|
|
603
612
|
cleanupStaleReviewsAsync(config);
|
|
613
|
+
startPoolBackgroundFetches(db, config);
|
|
604
614
|
|
|
605
615
|
// Open browser to landing page
|
|
606
616
|
const url = `http://localhost:${port}/`;
|
|
@@ -641,9 +651,12 @@ function formatAISuggestion(text, category) {
|
|
|
641
651
|
* @param {string} options.reviewEvent - 'DRAFT' or 'COMMENT'
|
|
642
652
|
* @param {string} options.commentStatus - 'draft' or 'submitted'
|
|
643
653
|
* @param {string} options.modeLabel - Display label for log messages (e.g., 'draft mode', 'action review mode')
|
|
654
|
+
* @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [externalPoolLifecycle] - Shared pool lifecycle instance (avoids creating a fresh singleton)
|
|
644
655
|
*/
|
|
645
|
-
async function performHeadlessReview(args, config, db, flags, options) {
|
|
656
|
+
async function performHeadlessReview(args, config, db, flags, options, externalPoolLifecycle = null) {
|
|
646
657
|
let prInfo = null;
|
|
658
|
+
let poolWorktreeId = null;
|
|
659
|
+
let poolLifecycle = null;
|
|
647
660
|
|
|
648
661
|
try {
|
|
649
662
|
// Get GitHub token (env var takes precedence over config)
|
|
@@ -746,6 +759,8 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
746
759
|
let checkoutScript;
|
|
747
760
|
let checkoutTimeout;
|
|
748
761
|
let worktreeConfig = null;
|
|
762
|
+
let poolSize = 0;
|
|
763
|
+
let resetScript = null;
|
|
749
764
|
if (isMatchingRepo) {
|
|
750
765
|
// Current directory is a checkout of the target repository
|
|
751
766
|
repositoryPath = currentDir;
|
|
@@ -753,10 +768,14 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
753
768
|
|
|
754
769
|
// Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
|
|
755
770
|
// even when running from inside the target repo, so they are not silently ignored.
|
|
756
|
-
const
|
|
771
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
772
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
773
|
+
const resolved = resolveRepoOptions(config, repository, repoSettings);
|
|
757
774
|
checkoutScript = resolved.checkoutScript;
|
|
758
775
|
checkoutTimeout = resolved.checkoutTimeout;
|
|
759
776
|
worktreeConfig = resolved.worktreeConfig;
|
|
777
|
+
poolSize = resolved.poolSize || 0;
|
|
778
|
+
resetScript = resolved.resetScript || null;
|
|
760
779
|
} else {
|
|
761
780
|
// Current directory is not the target repository - find or clone it
|
|
762
781
|
console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
|
|
@@ -778,15 +797,37 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
778
797
|
checkoutScript = result.checkoutScript;
|
|
779
798
|
checkoutTimeout = result.checkoutTimeout;
|
|
780
799
|
worktreeConfig = result.worktreeConfig;
|
|
800
|
+
// findRepositoryPath doesn't return pool config; resolve from DB + file config
|
|
801
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
802
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
803
|
+
const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repository, repoSettings);
|
|
804
|
+
poolSize = resolvedPoolSize || 0;
|
|
805
|
+
resetScript = config ? getRepoResetScript(config, repository) : null;
|
|
781
806
|
}
|
|
782
807
|
|
|
783
|
-
console.log('Setting up git worktree...');
|
|
784
808
|
const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
809
|
+
if (poolSize > 0) {
|
|
810
|
+
// Pool mode: use WorktreePoolLifecycle
|
|
811
|
+
console.log('Acquiring pool worktree...');
|
|
812
|
+
poolLifecycle = externalPoolLifecycle || new WorktreePoolLifecycle(db, config);
|
|
813
|
+
const result = await poolLifecycle.acquireForPR(
|
|
814
|
+
{ owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number, repository },
|
|
815
|
+
prData,
|
|
816
|
+
repositoryPath,
|
|
817
|
+
{ worktreeSourcePath, checkoutScript, checkoutTimeout, resetScript, worktreeConfig, poolSize }
|
|
818
|
+
);
|
|
819
|
+
worktreePath = result.worktreePath;
|
|
820
|
+
poolWorktreeId = result.worktreeId;
|
|
821
|
+
console.log('Pool worktree acquired');
|
|
822
|
+
} else {
|
|
823
|
+
// Non-pool mode: existing behavior
|
|
824
|
+
console.log('Setting up git worktree...');
|
|
825
|
+
({ path: worktreePath } = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
|
|
826
|
+
worktreeSourcePath,
|
|
827
|
+
checkoutScript,
|
|
828
|
+
checkoutTimeout
|
|
829
|
+
}));
|
|
830
|
+
}
|
|
790
831
|
|
|
791
832
|
console.log('Generating unified diff...');
|
|
792
833
|
diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
|
|
@@ -799,6 +840,11 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
799
840
|
skipWorktreeRecord: !!flags.useCheckout
|
|
800
841
|
});
|
|
801
842
|
|
|
843
|
+
// Persist review→worktree mapping in DB for pool usage tracking
|
|
844
|
+
if (poolWorktreeId && poolLifecycle) {
|
|
845
|
+
await poolLifecycle.setReviewOwner(poolWorktreeId, storedReviewId);
|
|
846
|
+
}
|
|
847
|
+
|
|
802
848
|
// Fire review.started hook for new reviews (non-blocking)
|
|
803
849
|
if (isNewReview) {
|
|
804
850
|
fireReviewStartedHook({
|
|
@@ -833,8 +879,12 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
833
879
|
|
|
834
880
|
// Run AI analysis
|
|
835
881
|
console.log('Running AI analysis (all 3 levels)...');
|
|
836
|
-
const
|
|
837
|
-
const
|
|
882
|
+
const cliProvider = repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
|
|
883
|
+
const model = flags.model || process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
|
|
884
|
+
const providerLoadSkills = config.providers?.[cliProvider]?.load_skills;
|
|
885
|
+
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
886
|
+
const providerOverrides = { load_skills: loadSkills };
|
|
887
|
+
const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
|
|
838
888
|
|
|
839
889
|
let analysisSummary = null;
|
|
840
890
|
try {
|
|
@@ -1039,7 +1089,19 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
|
|
|
1039
1089
|
// For other errors, show a clean message without stack trace
|
|
1040
1090
|
console.error(`\n❌ Error: ${error.message}\n`);
|
|
1041
1091
|
}
|
|
1042
|
-
process.
|
|
1092
|
+
process.exitCode = 1;
|
|
1093
|
+
} finally {
|
|
1094
|
+
// Release pool worktree after headless review completes (success or failure).
|
|
1095
|
+
// Headless reviews are fire-and-forget with no persistent browser session,
|
|
1096
|
+
// so the pool slot must be freed immediately.
|
|
1097
|
+
if (poolWorktreeId && poolLifecycle) {
|
|
1098
|
+
try {
|
|
1099
|
+
await poolLifecycle.releaseAfterHeadless(poolWorktreeId);
|
|
1100
|
+
logger.info(`Released pool worktree ${poolWorktreeId} after headless review`);
|
|
1101
|
+
} catch (releaseErr) {
|
|
1102
|
+
logger.error(`Failed to release pool worktree ${poolWorktreeId}: ${releaseErr.message}`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1043
1105
|
}
|
|
1044
1106
|
}
|
|
1045
1107
|
|
|
@@ -1050,14 +1112,15 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
|
|
|
1050
1112
|
* @param {Object} config - Application configuration
|
|
1051
1113
|
* @param {Object} db - Database instance
|
|
1052
1114
|
* @param {Object} flags - Parsed command line flags
|
|
1115
|
+
* @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Shared pool lifecycle instance
|
|
1053
1116
|
*/
|
|
1054
|
-
async function handleDraftModeReview(args, config, db, flags = {}) {
|
|
1117
|
+
async function handleDraftModeReview(args, config, db, flags = {}, poolLifecycle = null) {
|
|
1055
1118
|
await performHeadlessReview(args, config, db, flags, {
|
|
1056
1119
|
mode: 'draft',
|
|
1057
1120
|
reviewEvent: 'DRAFT',
|
|
1058
1121
|
commentStatus: 'draft',
|
|
1059
1122
|
modeLabel: 'draft mode'
|
|
1060
|
-
});
|
|
1123
|
+
}, poolLifecycle);
|
|
1061
1124
|
}
|
|
1062
1125
|
|
|
1063
1126
|
/**
|
|
@@ -1068,14 +1131,110 @@ async function handleDraftModeReview(args, config, db, flags = {}) {
|
|
|
1068
1131
|
* @param {Object} config - Application configuration
|
|
1069
1132
|
* @param {Object} db - Database instance
|
|
1070
1133
|
* @param {Object} flags - Parsed command line flags
|
|
1134
|
+
* @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Shared pool lifecycle instance
|
|
1071
1135
|
*/
|
|
1072
|
-
async function handleActionReview(args, config, db, flags = {}) {
|
|
1136
|
+
async function handleActionReview(args, config, db, flags = {}, poolLifecycle = null) {
|
|
1073
1137
|
await performHeadlessReview(args, config, db, flags, {
|
|
1074
1138
|
mode: 'review',
|
|
1075
1139
|
reviewEvent: 'COMMENT',
|
|
1076
1140
|
commentStatus: 'submitted',
|
|
1077
1141
|
modeLabel: 'action review mode'
|
|
1078
|
-
});
|
|
1142
|
+
}, poolLifecycle);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Start periodic background fetches for pool worktrees.
|
|
1147
|
+
* For each repo with pool_fetch_interval_minutes configured, run git fetch
|
|
1148
|
+
* on all pool worktrees serially, coldest first.
|
|
1149
|
+
* @param {Object} db - Database instance
|
|
1150
|
+
* @param {Object} config - Configuration object
|
|
1151
|
+
*/
|
|
1152
|
+
const POOL_FETCH_TICK_MS = 60 * 1000; // Check every minute
|
|
1153
|
+
|
|
1154
|
+
function startPoolBackgroundFetches(db, config) {
|
|
1155
|
+
let fetchInProgress = false;
|
|
1156
|
+
|
|
1157
|
+
const timer = setInterval(async () => {
|
|
1158
|
+
if (fetchInProgress) return;
|
|
1159
|
+
fetchInProgress = true;
|
|
1160
|
+
try {
|
|
1161
|
+
const poolRepo = new WorktreePoolRepository(db);
|
|
1162
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1163
|
+
|
|
1164
|
+
// Collect repos that might have pool config from either source.
|
|
1165
|
+
// Normalize to lowercase to match the database's COLLATE NOCASE identity.
|
|
1166
|
+
const repoNames = new Set(Object.keys(config.repos || {}).map(r => r.toLowerCase()));
|
|
1167
|
+
const allRepoSettings = await query(db, 'SELECT repository, pool_size, pool_fetch_interval_minutes FROM repo_settings WHERE pool_size IS NOT NULL OR pool_fetch_interval_minutes IS NOT NULL');
|
|
1168
|
+
for (const row of allRepoSettings) {
|
|
1169
|
+
repoNames.add(row.repository.toLowerCase());
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (repoNames.size === 0) return;
|
|
1173
|
+
|
|
1174
|
+
for (const repoName of repoNames) {
|
|
1175
|
+
const repoSettings = allRepoSettings.find(r => r.repository.toLowerCase() === repoName.toLowerCase()) || null;
|
|
1176
|
+
const { poolSize, poolFetchIntervalMinutes } = resolvePoolConfig(config, repoName, repoSettings);
|
|
1177
|
+
if (!poolSize || !poolFetchIntervalMinutes) continue;
|
|
1178
|
+
|
|
1179
|
+
const intervalMs = poolFetchIntervalMinutes * 60 * 1000;
|
|
1180
|
+
const worktrees = await poolRepo.findAllForFetch(repoName);
|
|
1181
|
+
|
|
1182
|
+
// Check if any worktree actually needs fetching before claiming the lease
|
|
1183
|
+
const needsFetch = worktrees.some(entry => {
|
|
1184
|
+
if (!entry.last_fetched_at) return true;
|
|
1185
|
+
const elapsed = Date.now() - new Date(entry.last_fetched_at).getTime();
|
|
1186
|
+
return elapsed >= intervalMs;
|
|
1187
|
+
});
|
|
1188
|
+
if (!needsFetch) continue;
|
|
1189
|
+
|
|
1190
|
+
// Atomically claim the fetch lease — skips if another instance holds it.
|
|
1191
|
+
// Pool worktrees share a git object store so concurrent fetches conflict.
|
|
1192
|
+
if (!(await repoSettingsRepo.tryClaimFetch(repoName))) {
|
|
1193
|
+
logger.info(`Background fetch skipped for ${repoName}: another instance is fetching`);
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
for (const entry of worktrees) {
|
|
1198
|
+
// Skip if fetched recently (within the configured interval)
|
|
1199
|
+
if (entry.last_fetched_at) {
|
|
1200
|
+
const elapsed = Date.now() - new Date(entry.last_fetched_at).getTime();
|
|
1201
|
+
if (elapsed < intervalMs) continue;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
logger.info(`Background fetch starting for ${repoName} pool worktree ${entry.id}`);
|
|
1205
|
+
try {
|
|
1206
|
+
const git = simpleGit(entry.path, { timeout: { block: 300000 } });
|
|
1207
|
+
const remotes = await git.getRemotes();
|
|
1208
|
+
const remote = remotes.find(r => r.name === 'origin') || remotes[0];
|
|
1209
|
+
if (remote) await git.fetch([remote.name, '--prune']);
|
|
1210
|
+
await poolRepo.updateLastFetched(entry.id);
|
|
1211
|
+
// Refresh the lease so the stale guard only needs to outlive
|
|
1212
|
+
// a single stalled fetch, not the entire serial loop.
|
|
1213
|
+
await repoSettingsRepo.refreshFetchLease(repoName);
|
|
1214
|
+
logger.info(`Background fetch complete for ${repoName} pool worktree ${entry.id}`);
|
|
1215
|
+
} catch (fetchErr) {
|
|
1216
|
+
logger.warn(`Background fetch failed for ${entry.id}: ${fetchErr.message}`);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
} finally {
|
|
1220
|
+
try {
|
|
1221
|
+
await repoSettingsRepo.markFetchFinished(repoName);
|
|
1222
|
+
} catch (finishErr) {
|
|
1223
|
+
logger.warn(`Failed to release fetch lease for ${repoName}: ${finishErr.message}`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
logger.error(`Background pool fetch error: ${err.message}`, err);
|
|
1229
|
+
} finally {
|
|
1230
|
+
fetchInProgress = false;
|
|
1231
|
+
}
|
|
1232
|
+
}, POOL_FETCH_TICK_MS);
|
|
1233
|
+
|
|
1234
|
+
// Don't keep the process alive just for background fetches
|
|
1235
|
+
if (timer.unref) timer.unref();
|
|
1236
|
+
|
|
1237
|
+
logger.info(`Background pool fetch ticker started (checking every ${POOL_FETCH_TICK_MS / 1000}s)`);
|
|
1079
1238
|
}
|
|
1080
1239
|
|
|
1081
1240
|
/**
|
package/src/routes/analyses.js
CHANGED
|
@@ -37,6 +37,7 @@ const { generateLocalDiff, computeLocalDiffDigest, getCurrentBranch } = require(
|
|
|
37
37
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
38
38
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
39
39
|
|
|
40
|
+
|
|
40
41
|
const router = express.Router();
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -493,6 +494,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
|
|
|
493
494
|
config: modeConfig,
|
|
494
495
|
excludePrevious,
|
|
495
496
|
serverPort,
|
|
497
|
+
providerOverrides = {},
|
|
498
|
+
providerOverridesMap = null,
|
|
496
499
|
hookContext = {},
|
|
497
500
|
} = modeContext;
|
|
498
501
|
|
|
@@ -563,38 +566,55 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
|
|
|
563
566
|
|
|
564
567
|
broadcastProgress(analysisId, initialStatus);
|
|
565
568
|
broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
|
|
569
|
+
|
|
570
|
+
// Register analysis hold for pool worktree usage tracking.
|
|
571
|
+
// startAnalysis is inside the try so a synchronous exception in setup still
|
|
572
|
+
// cleans up the hold (the .finally() on the promise chain only covers async errors).
|
|
573
|
+
const poolLifecycle = modeContext?.poolLifecycle;
|
|
574
|
+
let poolWorktreeId;
|
|
566
575
|
const effectiveConfig = modeConfig || {};
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
576
|
+
let analysisPromise;
|
|
577
|
+
try {
|
|
578
|
+
poolWorktreeId = await poolLifecycle?.startAnalysis(reviewId, analysisId);
|
|
579
|
+
if (hasHooks('analysis.started', effectiveConfig)) {
|
|
580
|
+
getCachedUser(effectiveConfig).then(user => {
|
|
581
|
+
fireHooks('analysis.started', buildAnalysisStartedPayload({
|
|
582
|
+
reviewId, analysisId, provider: 'council', model: councilId || 'inline-config',
|
|
583
|
+
mode: initialStatusExtra?.reviewType || 'pr',
|
|
584
|
+
prContext: hookContext.prContext, localContext: hookContext.localContext,
|
|
585
|
+
user,
|
|
586
|
+
}), effectiveConfig);
|
|
587
|
+
}).catch(err => { logger.warn(`Analysis hook failed: ${err.message}`); });
|
|
588
|
+
}
|
|
577
589
|
|
|
578
|
-
|
|
590
|
+
const analyzer = new Analyzer(db, 'council', 'council', providerOverrides, providerOverridesMap);
|
|
579
591
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
592
|
+
logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
|
|
593
|
+
logger.log('API', `Repository: ${repository}`, 'magenta');
|
|
594
|
+
logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
|
|
595
|
+
logger.log('API', `Config type: ${configType}`, 'magenta');
|
|
584
596
|
|
|
585
|
-
|
|
597
|
+
const progressCallback = createProgressCallback(analysisId);
|
|
586
598
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
599
|
+
const reviewContext = {
|
|
600
|
+
reviewId,
|
|
601
|
+
worktreePath,
|
|
602
|
+
prMetadata,
|
|
603
|
+
changedFiles,
|
|
604
|
+
instructions: { globalInstructions, repoInstructions, requestInstructions }
|
|
605
|
+
};
|
|
594
606
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
607
|
+
analysisPromise = isVoiceCentric
|
|
608
|
+
? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort })
|
|
609
|
+
: analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort });
|
|
610
|
+
} catch (setupError) {
|
|
611
|
+
// Synchronous setup failure — clean up the analysis hold immediately
|
|
612
|
+
reviewToAnalysisId.delete(reviewId);
|
|
613
|
+
if (poolWorktreeId) {
|
|
614
|
+
poolLifecycle?.endAnalysis(analysisId);
|
|
615
|
+
}
|
|
616
|
+
throw setupError;
|
|
617
|
+
}
|
|
598
618
|
|
|
599
619
|
analysisPromise
|
|
600
620
|
.then(async result => {
|
|
@@ -700,6 +720,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
|
|
|
700
720
|
.finally(() => {
|
|
701
721
|
// Clean up unified tracking map entry
|
|
702
722
|
reviewToAnalysisId.delete(reviewId);
|
|
723
|
+
// Remove pool worktree analysis hold
|
|
724
|
+
poolLifecycle?.endAnalysis(analysisId);
|
|
703
725
|
});
|
|
704
726
|
|
|
705
727
|
return { analysisId, runId };
|
package/src/routes/chat.js
CHANGED
|
@@ -21,6 +21,7 @@ const ws = require('../ws');
|
|
|
21
21
|
const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
22
22
|
const { buildChatStartedPayload, buildChatResumedPayload, buildChatHookContext, getCachedUser } = require('../hooks/payloads');
|
|
23
23
|
const { resolveFormat } = require('../utils/comment-formatter');
|
|
24
|
+
const { resolveLoadSkills } = require('../config');
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Fire a chat hook event (non-blocking). Skips async work when no hooks are configured.
|
|
@@ -226,6 +227,22 @@ async function getChatInstructions(db, review) {
|
|
|
226
227
|
return repoSettings ? repoSettings.default_chat_instructions : null;
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Resolve load_skills for a chat session based on repo settings and config.
|
|
232
|
+
* @param {Object} db - Database instance
|
|
233
|
+
* @param {Object} review - Review record
|
|
234
|
+
* @param {Object} config - App config
|
|
235
|
+
* @param {string} provider - Chat provider ID (e.g. 'pi')
|
|
236
|
+
* @returns {Promise<boolean|undefined>} Resolved load_skills, or undefined if no repo override
|
|
237
|
+
*/
|
|
238
|
+
async function getRepoLoadSkillsForChat(db, review, config, provider) {
|
|
239
|
+
if (!review || !review.repository) return undefined;
|
|
240
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
241
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
|
|
242
|
+
const providerLoadSkills = config.chat_providers?.[provider]?.load_skills;
|
|
243
|
+
return resolveLoadSkills(config, review.repository, repoSettings, providerLoadSkills);
|
|
244
|
+
}
|
|
245
|
+
|
|
229
246
|
/**
|
|
230
247
|
* Create a new chat session
|
|
231
248
|
*/
|
|
@@ -316,6 +333,10 @@ router.post('/api/chat/session', async (req, res) => {
|
|
|
316
333
|
? sessionPreamble + '\n\n' + initialContext
|
|
317
334
|
: sessionPreamble;
|
|
318
335
|
|
|
336
|
+
// Resolve load_skills from repo settings, falling back to provider config
|
|
337
|
+
const loadSkillsConfig = req.app.get('config') || {};
|
|
338
|
+
const loadSkills = await getRepoLoadSkillsForChat(db, review, loadSkillsConfig, provider);
|
|
339
|
+
|
|
319
340
|
const session = await chatSessionManager.createSession({
|
|
320
341
|
provider,
|
|
321
342
|
model,
|
|
@@ -323,7 +344,8 @@ router.post('/api/chat/session', async (req, res) => {
|
|
|
323
344
|
contextCommentId: contextCommentId || null,
|
|
324
345
|
systemPrompt: finalSystemPrompt,
|
|
325
346
|
cwd: resolvedCwd,
|
|
326
|
-
initialContext: initialContextWithPort
|
|
347
|
+
initialContext: initialContextWithPort,
|
|
348
|
+
loadSkills
|
|
327
349
|
});
|
|
328
350
|
|
|
329
351
|
logger.info(`Chat session created: ${session.id} (provider=${provider})`);
|
|
@@ -398,9 +420,10 @@ router.post('/api/chat/session/:id/message', async (req, res) => {
|
|
|
398
420
|
|
|
399
421
|
const systemPrompt = buildChatPrompt({ review, prData, chatInstructions, commentFormatTemplate: fmtConfig.template });
|
|
400
422
|
const cwd = await resolveReviewCwd(db, review);
|
|
423
|
+
const loadSkills = await getRepoLoadSkillsForChat(db, review, config, session.provider);
|
|
401
424
|
|
|
402
425
|
try {
|
|
403
|
-
await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
|
|
426
|
+
await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd, loadSkills });
|
|
404
427
|
unregisterChatBroadcast(sessionId);
|
|
405
428
|
registerChatBroadcast(chatSessionManager, sessionId, req.socket.localPort);
|
|
406
429
|
logger.info(`[ChatRoute] Auto-resumed session ${sessionId} for message delivery`);
|
|
@@ -540,8 +563,9 @@ router.post('/api/chat/session/:id/resume', async (req, res) => {
|
|
|
540
563
|
|
|
541
564
|
const systemPrompt = buildChatPrompt({ review, prData, chatInstructions, commentFormatTemplate: fmtConfig.template });
|
|
542
565
|
const cwd = await resolveReviewCwd(db, review);
|
|
566
|
+
const loadSkills = await getRepoLoadSkillsForChat(db, review, config, session.provider);
|
|
543
567
|
|
|
544
|
-
await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
|
|
568
|
+
await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd, loadSkills });
|
|
545
569
|
unregisterChatBroadcast(sessionId);
|
|
546
570
|
const serverPort = req.socket.localPort;
|
|
547
571
|
registerChatBroadcast(chatSessionManager, sessionId, serverPort);
|
package/src/routes/config.js
CHANGED
|
@@ -98,7 +98,10 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
98
98
|
local_path: null,
|
|
99
99
|
default_council_id: null,
|
|
100
100
|
default_tab: null,
|
|
101
|
-
default_chat_instructions: null
|
|
101
|
+
default_chat_instructions: null,
|
|
102
|
+
pool_size: null,
|
|
103
|
+
pool_fetch_interval_minutes: null,
|
|
104
|
+
load_skills: null
|
|
102
105
|
});
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -111,6 +114,9 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
111
114
|
default_council_id: settings.default_council_id,
|
|
112
115
|
default_tab: settings.default_tab,
|
|
113
116
|
default_chat_instructions: settings.default_chat_instructions,
|
|
117
|
+
pool_size: settings.pool_size ?? null,
|
|
118
|
+
pool_fetch_interval_minutes: settings.pool_fetch_interval_minutes ?? null,
|
|
119
|
+
load_skills: settings.load_skills ?? null,
|
|
114
120
|
created_at: settings.created_at,
|
|
115
121
|
updated_at: settings.updated_at
|
|
116
122
|
});
|
|
@@ -130,14 +136,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
130
136
|
router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
131
137
|
try {
|
|
132
138
|
const { owner, repo } = req.params;
|
|
133
|
-
const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab, default_chat_instructions } = req.body;
|
|
139
|
+
const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab, default_chat_instructions, pool_size, pool_fetch_interval_minutes, load_skills } = req.body;
|
|
134
140
|
const repository = normalizeRepository(owner, repo);
|
|
135
141
|
const db = req.app.get('db');
|
|
136
142
|
|
|
137
143
|
// Validate that at least one setting is provided
|
|
138
|
-
if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined && default_chat_instructions === undefined) {
|
|
144
|
+
if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined && default_chat_instructions === undefined && pool_size === undefined && pool_fetch_interval_minutes === undefined && load_skills === undefined) {
|
|
139
145
|
return res.status(400).json({
|
|
140
|
-
error: 'At least one setting
|
|
146
|
+
error: 'At least one setting must be provided'
|
|
141
147
|
});
|
|
142
148
|
}
|
|
143
149
|
|
|
@@ -149,7 +155,10 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
149
155
|
local_path,
|
|
150
156
|
default_council_id,
|
|
151
157
|
default_tab,
|
|
152
|
-
default_chat_instructions
|
|
158
|
+
default_chat_instructions,
|
|
159
|
+
pool_size,
|
|
160
|
+
pool_fetch_interval_minutes,
|
|
161
|
+
load_skills
|
|
153
162
|
});
|
|
154
163
|
|
|
155
164
|
logger.info(`Saved repo settings for ${repository}`);
|
|
@@ -165,6 +174,9 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
165
174
|
default_council_id: settings.default_council_id,
|
|
166
175
|
default_tab: settings.default_tab,
|
|
167
176
|
default_chat_instructions: settings.default_chat_instructions,
|
|
177
|
+
pool_size: settings.pool_size ?? null,
|
|
178
|
+
pool_fetch_interval_minutes: settings.pool_fetch_interval_minutes ?? null,
|
|
179
|
+
load_skills: settings.load_skills ?? null,
|
|
168
180
|
updated_at: settings.updated_at
|
|
169
181
|
}
|
|
170
182
|
});
|