@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.
Files changed (46) hide show
  1. package/README.md +7 -6
  2. package/package.json +5 -4
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -1
  6. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +8 -1
  7. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +8 -7
  8. package/public/css/repo-settings.css +347 -0
  9. package/public/index.html +46 -9
  10. package/public/js/components/AIPanel.js +79 -37
  11. package/public/js/components/DiffOptionsDropdown.js +84 -1
  12. package/public/js/index.js +31 -6
  13. package/public/js/modules/analysis-history.js +11 -7
  14. package/public/js/pr.js +22 -0
  15. package/public/js/repo-settings.js +334 -6
  16. package/public/repo-settings.html +29 -0
  17. package/src/ai/analyzer.js +28 -19
  18. package/src/ai/claude-cli.js +2 -0
  19. package/src/ai/claude-provider.js +4 -1
  20. package/src/ai/prompts/baseline/consolidation/balanced.js +6 -4
  21. package/src/ai/prompts/baseline/consolidation/fast.js +6 -2
  22. package/src/ai/prompts/baseline/consolidation/thorough.js +7 -6
  23. package/src/ai/prompts/baseline/orchestration/balanced.js +13 -1
  24. package/src/ai/prompts/baseline/orchestration/fast.js +12 -1
  25. package/src/ai/prompts/baseline/orchestration/thorough.js +8 -7
  26. package/src/ai/provider.js +7 -6
  27. package/src/chat/session-manager.js +6 -3
  28. package/src/config.js +230 -38
  29. package/src/database.js +766 -38
  30. package/src/git/worktree-pool-lifecycle.js +674 -0
  31. package/src/git/worktree-pool-usage.js +216 -0
  32. package/src/git/worktree.js +46 -13
  33. package/src/main.js +185 -26
  34. package/src/routes/analyses.js +48 -26
  35. package/src/routes/chat.js +27 -3
  36. package/src/routes/config.js +17 -5
  37. package/src/routes/executable-analysis.js +38 -19
  38. package/src/routes/local.js +19 -6
  39. package/src/routes/mcp.js +13 -2
  40. package/src/routes/pr.js +72 -29
  41. package/src/routes/setup.js +41 -4
  42. package/src/routes/stack-analysis.js +29 -10
  43. package/src/routes/worktrees.js +294 -9
  44. package/src/server.js +20 -3
  45. package/src/setup/pr-setup.js +161 -27
  46. package/src/ws/server.js +51 -1
package/src/database.js CHANGED
@@ -2,6 +2,7 @@
2
2
  const Database = require('better-sqlite3');
3
3
  const path = require('path');
4
4
  const fs = require('fs').promises;
5
+ const fsSync = require('fs');
5
6
  const { getConfigDir } = require('./config');
6
7
 
7
8
  let dbPath = null;
@@ -20,7 +21,7 @@ function getDbPath() {
20
21
  /**
21
22
  * Current schema version - increment this when adding new migrations
22
23
  */
23
- const CURRENT_SCHEMA_VERSION = 36;
24
+ const CURRENT_SCHEMA_VERSION = 43;
24
25
 
25
26
  /**
26
27
  * Database schema SQL statements
@@ -142,7 +143,7 @@ const SCHEMA_SQL = {
142
143
  repo_settings: `
143
144
  CREATE TABLE IF NOT EXISTS repo_settings (
144
145
  id INTEGER PRIMARY KEY AUTOINCREMENT,
145
- repository TEXT NOT NULL UNIQUE,
146
+ repository TEXT NOT NULL UNIQUE COLLATE NOCASE,
146
147
  default_instructions TEXT,
147
148
  default_provider TEXT,
148
149
  default_model TEXT,
@@ -151,6 +152,11 @@ const SCHEMA_SQL = {
151
152
  default_chat_instructions TEXT,
152
153
  local_path TEXT,
153
154
  auto_branch_review INTEGER DEFAULT 0,
155
+ pool_size INTEGER,
156
+ pool_fetch_interval_minutes INTEGER,
157
+ pool_fetch_started_at TEXT,
158
+ pool_fetch_finished_at TEXT,
159
+ load_skills INTEGER,
154
160
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
155
161
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
156
162
  )
@@ -277,6 +283,21 @@ const SCHEMA_SQL = {
277
283
  collection TEXT NOT NULL,
278
284
  fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
279
285
  )
286
+ `,
287
+
288
+ worktree_pool: `
289
+ CREATE TABLE IF NOT EXISTS worktree_pool (
290
+ id TEXT PRIMARY KEY,
291
+ repository TEXT NOT NULL,
292
+ path TEXT NOT NULL UNIQUE,
293
+ status TEXT NOT NULL DEFAULT 'available'
294
+ CHECK(status IN ('available', 'in_use', 'switching', 'creating')),
295
+ current_pr_number INTEGER,
296
+ current_review_id INTEGER,
297
+ last_switched_at TEXT,
298
+ last_fetched_at TEXT,
299
+ created_at TEXT NOT NULL
300
+ )
280
301
  `
281
302
  };
282
303
 
@@ -293,7 +314,7 @@ const INDEX_SQL = [
293
314
  'CREATE INDEX IF NOT EXISTS idx_pr_metadata_last_accessed ON pr_metadata(last_accessed_at)',
294
315
  'CREATE INDEX IF NOT EXISTS idx_worktrees_last_accessed ON worktrees(last_accessed_at)',
295
316
  'CREATE INDEX IF NOT EXISTS idx_worktrees_repo ON worktrees(repository)',
296
- 'CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_settings_repository ON repo_settings(repository)',
317
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_settings_repository ON repo_settings(repository COLLATE NOCASE)',
297
318
  'CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_local ON reviews(local_path, local_head_sha, local_head_branch) WHERE review_type = \'local\'',
298
319
  // Partial unique index for PR reviews only (NULL pr_number values for local reviews should not conflict)
299
320
  'CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_pr_unique ON reviews(pr_number, repository) WHERE review_type = \'pr\'',
@@ -315,7 +336,11 @@ const INDEX_SQL = [
315
336
  // Context files indexes
316
337
  'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)',
317
338
  // GitHub PR cache indexes
318
- 'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)'
339
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)',
340
+ // Worktree pool indexes
341
+ 'CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)',
342
+ 'CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)',
343
+ 'CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)'
319
344
  ];
320
345
 
321
346
  /**
@@ -1635,6 +1660,212 @@ const MIGRATIONS = {
1635
1660
  }
1636
1661
 
1637
1662
  console.log('Migration to schema version 36 complete');
1663
+ },
1664
+
1665
+ 37: (db) => {
1666
+ console.log('Running migration to schema version 37...');
1667
+
1668
+ if (!tableExists(db, 'worktree_pool')) {
1669
+ db.exec(SCHEMA_SQL.worktree_pool);
1670
+ console.log(' Created worktree_pool table');
1671
+ }
1672
+
1673
+ db.exec('CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)');
1674
+ db.exec('CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)');
1675
+ db.exec('CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)');
1676
+
1677
+ console.log('Migration to schema version 37 complete');
1678
+ },
1679
+
1680
+ // Migration to version 38: Add current_review_id to worktree_pool for persistent ownership
1681
+ 38: (db) => {
1682
+ console.log('Running migration to schema version 38...');
1683
+
1684
+ if (tableExists(db, 'worktree_pool') && !columnExists(db, 'worktree_pool', 'current_review_id')) {
1685
+ db.prepare('ALTER TABLE worktree_pool ADD COLUMN current_review_id INTEGER').run();
1686
+ console.log(' Added column current_review_id to worktree_pool');
1687
+ }
1688
+
1689
+ console.log('Migration to schema version 38 complete');
1690
+ },
1691
+
1692
+ 39: (db) => {
1693
+ console.log('Running migration to schema version 39: Add creating status to worktree_pool...');
1694
+
1695
+ if (!tableExists(db, 'worktree_pool')) {
1696
+ console.log(' worktree_pool table does not exist, skipping');
1697
+ console.log('Migration to schema version 39 complete');
1698
+ return;
1699
+ }
1700
+
1701
+ // SQLite does not support ALTER TABLE to modify CHECK constraints.
1702
+ // Rebuild the table with the updated constraint.
1703
+ db.pragma('foreign_keys = OFF');
1704
+
1705
+ db.prepare(`CREATE TABLE IF NOT EXISTS worktree_pool_rebuild (
1706
+ id TEXT PRIMARY KEY,
1707
+ repository TEXT NOT NULL,
1708
+ path TEXT NOT NULL UNIQUE,
1709
+ status TEXT NOT NULL DEFAULT 'available'
1710
+ CHECK(status IN ('available', 'in_use', 'switching', 'creating')),
1711
+ current_pr_number INTEGER,
1712
+ current_review_id INTEGER,
1713
+ last_switched_at TEXT,
1714
+ last_fetched_at TEXT,
1715
+ created_at TEXT NOT NULL
1716
+ )`).run();
1717
+
1718
+ const cols = [
1719
+ 'id', 'repository', 'path', 'status',
1720
+ 'current_pr_number', 'current_review_id',
1721
+ 'last_switched_at', 'last_fetched_at', 'created_at'
1722
+ ].join(', ');
1723
+
1724
+ const rebuild = db.transaction(() => {
1725
+ db.prepare(`INSERT INTO worktree_pool_rebuild (${cols}) SELECT ${cols} FROM worktree_pool`).run();
1726
+ db.prepare('DROP TABLE worktree_pool').run();
1727
+ db.prepare('ALTER TABLE worktree_pool_rebuild RENAME TO worktree_pool').run();
1728
+ });
1729
+ rebuild();
1730
+
1731
+ db.pragma('foreign_keys = ON');
1732
+
1733
+ // Recreate indexes on the rebuilt table (must match INDEX_SQL definitions)
1734
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)').run();
1735
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)').run();
1736
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)').run();
1737
+
1738
+ console.log(' Rebuilt worktree_pool with creating status in CHECK constraint');
1739
+ console.log('Migration to schema version 39 complete');
1740
+ },
1741
+ 40: (db) => {
1742
+ console.log('Running migration to schema version 40: Add pool settings to repo_settings...');
1743
+ const addColumnIfNotExists = (table, column, definition) => {
1744
+ const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
1745
+ const columnExists = tableInfo.some(col => col.name === column);
1746
+ if (!columnExists) {
1747
+ db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
1748
+ console.log(` Added column ${column} to ${table}`);
1749
+ }
1750
+ };
1751
+ addColumnIfNotExists('repo_settings', 'pool_size', 'INTEGER');
1752
+ addColumnIfNotExists('repo_settings', 'pool_fetch_interval_minutes', 'INTEGER');
1753
+ console.log('Migration to schema version 40 complete');
1754
+ },
1755
+
1756
+ 41: (db) => {
1757
+ console.log('Running migration to schema version 41: Add pool fetch coordination columns to repo_settings...');
1758
+ const addColumnIfNotExists = (table, column, definition) => {
1759
+ const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
1760
+ const columnExists = tableInfo.some(col => col.name === column);
1761
+ if (!columnExists) {
1762
+ db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
1763
+ console.log(` Added column ${column} to ${table}`);
1764
+ }
1765
+ };
1766
+ addColumnIfNotExists('repo_settings', 'pool_fetch_started_at', 'TEXT');
1767
+ addColumnIfNotExists('repo_settings', 'pool_fetch_finished_at', 'TEXT');
1768
+ console.log('Migration to schema version 41 complete');
1769
+ },
1770
+
1771
+ 42: (db) => {
1772
+ console.log('Running migration to schema version 42: Add COLLATE NOCASE to repo_settings.repository...');
1773
+
1774
+ if (!tableExists(db, 'repo_settings')) {
1775
+ console.log(' repo_settings table does not exist, skipping');
1776
+ console.log('Migration to schema version 42 complete');
1777
+ return;
1778
+ }
1779
+
1780
+ // SQLite does not support ALTER TABLE to modify column collation.
1781
+ // Rebuild the table with the updated column definition.
1782
+ db.pragma('foreign_keys = OFF');
1783
+ try {
1784
+ db.prepare('DROP TABLE IF EXISTS repo_settings_rebuild').run();
1785
+
1786
+ db.prepare(`CREATE TABLE IF NOT EXISTS repo_settings_rebuild (
1787
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1788
+ repository TEXT NOT NULL UNIQUE COLLATE NOCASE,
1789
+ default_instructions TEXT,
1790
+ default_provider TEXT,
1791
+ default_model TEXT,
1792
+ default_council_id TEXT,
1793
+ default_tab TEXT,
1794
+ default_chat_instructions TEXT,
1795
+ local_path TEXT,
1796
+ auto_branch_review INTEGER DEFAULT 0,
1797
+ pool_size INTEGER,
1798
+ pool_fetch_interval_minutes INTEGER,
1799
+ pool_fetch_started_at TEXT,
1800
+ pool_fetch_finished_at TEXT,
1801
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
1802
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
1803
+ )`).run();
1804
+
1805
+ // Auto-merge case-duplicate repositories, keeping the most recently updated row.
1806
+ const dupes = db.prepare(`SELECT LOWER(repository) as repo, COUNT(*) as cnt FROM repo_settings GROUP BY LOWER(repository) HAVING cnt > 1`).all();
1807
+ if (dupes.length > 0) {
1808
+ const removedRows = [];
1809
+ for (const { repo } of dupes) {
1810
+ const rows = db.prepare(`SELECT * FROM repo_settings WHERE LOWER(repository) = ? ORDER BY julianday(updated_at) DESC, id DESC`).all(repo);
1811
+ const kept = rows[0];
1812
+ const discarded = rows.slice(1);
1813
+ for (const row of discarded) {
1814
+ removedRows.push(row);
1815
+ db.prepare('DELETE FROM repo_settings WHERE id = ?').run(row.id);
1816
+ }
1817
+ console.warn(` [migration 42] Case-duplicate repo "${repo}": kept id=${kept.id} (updated ${kept.updated_at}), removed ${discarded.length} older row(s)`);
1818
+ }
1819
+
1820
+ // Write backup of removed rows to ~/.pair-review/ so no data is truly lost
1821
+ try {
1822
+ const backupPath = path.join(getConfigDir(), `migration-42-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
1823
+ fsSync.writeFileSync(backupPath, JSON.stringify(removedRows, null, 2));
1824
+ console.warn(` [migration 42] Backup of ${removedRows.length} removed row(s) written to ${backupPath}`);
1825
+ } catch (backupErr) {
1826
+ console.warn(` [migration 42] Warning: could not write backup file: ${backupErr.message}`);
1827
+ console.warn(` [migration 42] Removed row data: ${JSON.stringify(removedRows)}`);
1828
+ }
1829
+ }
1830
+
1831
+ const cols = [
1832
+ 'id', 'repository', 'default_instructions', 'default_provider',
1833
+ 'default_model', 'default_council_id', 'default_tab',
1834
+ 'default_chat_instructions', 'local_path', 'auto_branch_review',
1835
+ 'pool_size', 'pool_fetch_interval_minutes',
1836
+ 'pool_fetch_started_at', 'pool_fetch_finished_at',
1837
+ 'created_at', 'updated_at'
1838
+ ].join(', ');
1839
+
1840
+ const rebuild = db.transaction(() => {
1841
+ db.prepare(`INSERT INTO repo_settings_rebuild (${cols}) SELECT ${cols} FROM repo_settings`).run();
1842
+ db.prepare('DROP TABLE repo_settings').run();
1843
+ db.prepare('ALTER TABLE repo_settings_rebuild RENAME TO repo_settings').run();
1844
+ });
1845
+ rebuild();
1846
+
1847
+ // Recreate the index with COLLATE NOCASE (must match INDEX_SQL definition)
1848
+ db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_settings_repository ON repo_settings(repository COLLATE NOCASE)').run();
1849
+
1850
+ console.log(' Rebuilt repo_settings with COLLATE NOCASE on repository column');
1851
+ console.log('Migration to schema version 42 complete');
1852
+ } finally {
1853
+ db.pragma('foreign_keys = ON');
1854
+ }
1855
+ },
1856
+
1857
+ 43: (db) => {
1858
+ console.log('Running migration to schema version 43: Add load_skills to repo_settings...');
1859
+ const addColumnIfNotExists = (table, column, definition) => {
1860
+ const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
1861
+ const columnExists = tableInfo.some(col => col.name === column);
1862
+ if (!columnExists) {
1863
+ db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
1864
+ console.log(` Added column ${column} to ${table}`);
1865
+ }
1866
+ };
1867
+ addColumnIfNotExists('repo_settings', 'load_skills', 'INTEGER');
1868
+ console.log('Migration to schema version 43 complete');
1638
1869
  }
1639
1870
  };
1640
1871
 
@@ -1903,6 +2134,7 @@ function generateWorktreeId(length = 3) {
1903
2134
  return `pair-review--${randomPart}`;
1904
2135
  }
1905
2136
 
2137
+
1906
2138
  /**
1907
2139
  * WorktreeRepository class for managing worktree database records
1908
2140
  */
@@ -1917,29 +2149,34 @@ class WorktreeRepository {
1917
2149
 
1918
2150
  /**
1919
2151
  * Create a new worktree record
1920
- * @param {Object} prInfo - PR information { prNumber, repository, branch, path }
2152
+ * @param {Object} prInfo - PR information { prNumber, repository, branch, path, explicitId }
1921
2153
  * @returns {Promise<Object>} Created worktree record
1922
2154
  */
1923
2155
  async create(prInfo) {
1924
- const { prNumber, repository, branch, path: worktreePath } = prInfo;
2156
+ const { prNumber, repository, branch, path: worktreePath, explicitId } = prInfo;
1925
2157
 
1926
- // Generate unique ID (retry if collision)
1927
2158
  let id;
1928
- let attempts = 0;
1929
- const maxAttempts = 10;
1930
-
1931
- while (attempts < maxAttempts) {
1932
- id = generateWorktreeId();
1933
- const existing = await queryOne(this.db,
1934
- 'SELECT id FROM worktrees WHERE id = ?',
1935
- [id]
1936
- );
1937
- if (!existing) break;
1938
- attempts++;
1939
- }
2159
+ if (explicitId) {
2160
+ // Use the caller-supplied ID (e.g. pool worktree ID)
2161
+ id = explicitId;
2162
+ } else {
2163
+ // Generate unique ID (retry if collision)
2164
+ let attempts = 0;
2165
+ const maxAttempts = 10;
2166
+
2167
+ while (attempts < maxAttempts) {
2168
+ id = generateWorktreeId();
2169
+ const existing = await queryOne(this.db,
2170
+ 'SELECT id FROM worktrees WHERE id = ?',
2171
+ [id]
2172
+ );
2173
+ if (!existing) break;
2174
+ attempts++;
2175
+ }
1940
2176
 
1941
- if (attempts >= maxAttempts) {
1942
- throw new Error('Failed to generate unique worktree ID after maximum attempts');
2177
+ if (attempts >= maxAttempts) {
2178
+ throw new Error('Failed to generate unique worktree ID after maximum attempts');
2179
+ }
1943
2180
  }
1944
2181
 
1945
2182
  const now = new Date().toISOString();
@@ -2016,10 +2253,11 @@ class WorktreeRepository {
2016
2253
  const dateStr = olderThan instanceof Date ? olderThan.toISOString() : olderThan;
2017
2254
 
2018
2255
  const rows = await query(this.db, `
2019
- SELECT id, pr_number, repository, branch, path, created_at, last_accessed_at
2020
- FROM worktrees
2021
- WHERE last_accessed_at < ?
2022
- ORDER BY last_accessed_at ASC
2256
+ SELECT w.id, w.pr_number, w.repository, w.branch, w.path, w.created_at, w.last_accessed_at
2257
+ FROM worktrees w
2258
+ LEFT JOIN worktree_pool wp ON w.id = wp.id
2259
+ WHERE w.last_accessed_at < ? AND wp.id IS NULL
2260
+ ORDER BY w.last_accessed_at ASC
2023
2261
  `, [dateStr]);
2024
2262
 
2025
2263
  return rows;
@@ -2054,6 +2292,20 @@ class WorktreeRepository {
2054
2292
  return rows;
2055
2293
  }
2056
2294
 
2295
+ /**
2296
+ * Find all worktrees for a given repository.
2297
+ * @param {string} repository - Repository in "owner/repo" format
2298
+ * @returns {Promise<Array<Object>>} Array of worktree records ordered by last_accessed_at DESC
2299
+ */
2300
+ async findAllByRepository(repository) {
2301
+ return await query(this.db, `
2302
+ SELECT id, pr_number, repository, branch, path, created_at, last_accessed_at
2303
+ FROM worktrees
2304
+ WHERE repository = ? COLLATE NOCASE
2305
+ ORDER BY last_accessed_at DESC
2306
+ `, [repository]);
2307
+ }
2308
+
2057
2309
  /**
2058
2310
  * Update the path of an existing worktree record
2059
2311
  * @param {string} id - Worktree ID
@@ -2075,16 +2327,47 @@ class WorktreeRepository {
2075
2327
  * Get or create a worktree record (upsert-like behavior)
2076
2328
  * If a worktree exists for the PR, update its last_accessed_at and return it
2077
2329
  * Otherwise, create a new record
2078
- * @param {Object} prInfo - PR information { prNumber, repository, branch, path }
2330
+ * @param {Object} prInfo - PR information { prNumber, repository, branch, path, explicitId }
2079
2331
  * @returns {Promise<Object>} Worktree record (existing or newly created)
2080
2332
  */
2081
2333
  async getOrCreate(prInfo) {
2082
- const { prNumber, repository } = prInfo;
2334
+ const { prNumber, repository, explicitId } = prInfo;
2083
2335
 
2084
2336
  // Check if worktree already exists
2085
2337
  const existing = await this.findByPR(prNumber, repository);
2086
2338
 
2087
2339
  if (existing) {
2340
+ // If explicitId is provided and differs from the existing record's ID,
2341
+ // migrate the record to use the new ID. This happens when pool mode is
2342
+ // enabled for a repo that already has legacy (non-pool) worktree records:
2343
+ // the pool slot has its own ID that the worktrees row must match.
2344
+ if (explicitId && existing.id !== explicitId) {
2345
+ const now = new Date().toISOString();
2346
+ await run(this.db, 'BEGIN IMMEDIATE');
2347
+ try {
2348
+ // Delete the old record and create a new one with the pool ID.
2349
+ // We can't UPDATE the primary key directly in SQLite.
2350
+ await run(this.db, `DELETE FROM worktrees WHERE id = ?`, [existing.id]);
2351
+ await run(this.db, `
2352
+ INSERT INTO worktrees (id, pr_number, repository, branch, path, created_at, last_accessed_at)
2353
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2354
+ `, [explicitId, prNumber, repository, prInfo.branch, prInfo.path, existing.created_at, now]);
2355
+ await run(this.db, 'COMMIT');
2356
+ } catch (err) {
2357
+ await run(this.db, 'ROLLBACK');
2358
+ throw err;
2359
+ }
2360
+ return {
2361
+ id: explicitId,
2362
+ pr_number: prNumber,
2363
+ repository,
2364
+ branch: prInfo.branch,
2365
+ path: prInfo.path,
2366
+ created_at: existing.created_at,
2367
+ last_accessed_at: now
2368
+ };
2369
+ }
2370
+
2088
2371
  // Update last_accessed_at and potentially the path
2089
2372
  const now = new Date().toISOString();
2090
2373
  await run(this.db, `
@@ -2105,6 +2388,63 @@ class WorktreeRepository {
2105
2388
  return this.create(prInfo);
2106
2389
  }
2107
2390
 
2391
+ /**
2392
+ * Switch a worktree's PR assignment (for pool worktree switching).
2393
+ * Unlike getOrCreate, this updates an existing record by ID rather than by PR number.
2394
+ * Removes any conflicting non-pool worktree record for the target PR to avoid
2395
+ * UNIQUE(pr_number, repository) violations when transitioning from non-pool to pool mode.
2396
+ * @param {string} id - Worktree ID
2397
+ * @param {number} prNumber - New PR number
2398
+ * @param {string} branch - New branch name
2399
+ * @returns {Promise<string[]>} Paths of deleted non-pool worktree records (for filesystem cleanup)
2400
+ */
2401
+ async switchPR(id, prNumber, branch) {
2402
+ const now = new Date().toISOString();
2403
+ let deletedPaths = [];
2404
+ // Look up this worktree's repository so we can check for conflicts
2405
+ const self = await queryOne(this.db, `SELECT repository FROM worktrees WHERE id = ?`, [id]);
2406
+ if (self && self.repository) {
2407
+ // Wrap SELECT + DELETE + UPDATE in a transaction to avoid partial state
2408
+ await run(this.db, 'BEGIN IMMEDIATE');
2409
+ try {
2410
+ // Collect paths of conflicting non-pool worktree records before deleting
2411
+ // (the caller needs these to clean up the actual git worktree directories on disk)
2412
+ const conflicting = this.db.prepare(`
2413
+ SELECT path FROM worktrees
2414
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE AND id != ?
2415
+ AND id NOT IN (SELECT id FROM worktree_pool)
2416
+ `).all(prNumber, self.repository, id);
2417
+ deletedPaths = conflicting.map(row => row.path).filter(Boolean);
2418
+
2419
+ // Remove any conflicting non-pool worktree record for the target PR
2420
+ // (can exist when transitioning a repo from non-pool to pool mode)
2421
+ await run(this.db, `
2422
+ DELETE FROM worktrees
2423
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE AND id != ?
2424
+ AND id NOT IN (SELECT id FROM worktree_pool)
2425
+ `, [prNumber, self.repository, id]);
2426
+ await run(this.db, `UPDATE worktrees SET pr_number = ?, branch = ?, last_accessed_at = ? WHERE id = ?`, [prNumber, branch, now, id]);
2427
+ await run(this.db, 'COMMIT');
2428
+ } catch (err) {
2429
+ await run(this.db, 'ROLLBACK');
2430
+ throw err;
2431
+ }
2432
+ } else {
2433
+ await run(this.db, `UPDATE worktrees SET pr_number = ?, branch = ?, last_accessed_at = ? WHERE id = ?`, [prNumber, branch, now, id]);
2434
+ }
2435
+ return deletedPaths;
2436
+ }
2437
+
2438
+
2439
+ /**
2440
+ * Find a worktree record by its filesystem path.
2441
+ * @param {string} worktreePath - Absolute path to the worktree
2442
+ * @returns {Promise<Object|null>} Worktree record or null
2443
+ */
2444
+ async findByPath(worktreePath) {
2445
+ return queryOne(this.db, `SELECT * FROM worktrees WHERE path = ?`, [worktreePath]);
2446
+ }
2447
+
2108
2448
  /**
2109
2449
  * Count total worktrees in the database
2110
2450
  * @returns {Promise<number>} Total count
@@ -2115,6 +2455,333 @@ class WorktreeRepository {
2115
2455
  }
2116
2456
  }
2117
2457
 
2458
+ /**
2459
+ * WorktreePoolRepository class for managing pool worktree database records
2460
+ */
2461
+ class WorktreePoolRepository {
2462
+ constructor(db) {
2463
+ this.db = db;
2464
+ }
2465
+
2466
+ /**
2467
+ * Create a pool entry for a worktree.
2468
+ * @param {object} params
2469
+ * @param {string} params.id - Pool worktree ID (e.g., 'pool-abc')
2470
+ * @param {string} params.repository - owner/repo
2471
+ * @param {string} params.path - Absolute filesystem path
2472
+ * @param {number} [params.prNumber] - If provided, insert as 'in_use' for this PR (avoids race with claimAvailable)
2473
+ */
2474
+ async create({ id, repository, path, prNumber }) {
2475
+ const now = new Date().toISOString();
2476
+ if (prNumber != null) {
2477
+ await run(this.db, `INSERT INTO worktree_pool (id, repository, path, status, current_pr_number, last_switched_at, created_at) VALUES (?, ?, ?, 'in_use', ?, ?, ?)`, [id, repository, path, prNumber, now, now]);
2478
+ } else {
2479
+ await run(this.db, `INSERT INTO worktree_pool (id, repository, path, status, created_at) VALUES (?, ?, ?, 'available', ?)`, [id, repository, path, now]);
2480
+ }
2481
+ }
2482
+
2483
+ /**
2484
+ * Find an available (evictable) pool worktree for a repository,
2485
+ * ordered by LRU (oldest last_switched_at first, NULLs first).
2486
+ */
2487
+ async findAvailable(repository) {
2488
+ return await queryOne(this.db, `
2489
+ SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
2490
+ FROM worktree_pool
2491
+ WHERE repository = ? COLLATE NOCASE AND status = 'available'
2492
+ ORDER BY last_switched_at ASC NULLS FIRST
2493
+ LIMIT 1
2494
+ `, [repository]);
2495
+ }
2496
+
2497
+ /**
2498
+ * Find a pool worktree currently assigned to a PR.
2499
+ */
2500
+ async findByPR(prNumber, repository) {
2501
+ return await queryOne(this.db, `
2502
+ SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
2503
+ FROM worktree_pool
2504
+ WHERE current_pr_number = ? AND repository = ? COLLATE NOCASE
2505
+ `, [prNumber, repository]);
2506
+ }
2507
+
2508
+ /**
2509
+ * Count pool worktrees for a repository.
2510
+ */
2511
+ async countForRepo(repository) {
2512
+ const row = await queryOne(this.db, `SELECT COUNT(*) as count FROM worktree_pool WHERE repository = ? COLLATE NOCASE`, [repository]);
2513
+ return row ? row.count : 0;
2514
+ }
2515
+
2516
+ /**
2517
+ * Find worktrees for a repository that are NOT in the pool.
2518
+ * Joins reviews to get the review ID in one query (avoids N+1).
2519
+ *
2520
+ * @param {string} repository - Repository in "owner/repo" format
2521
+ * @returns {Promise<Array<{id: string, path: string, pr_number: number, repository: string, reviewId: number|null}>>}
2522
+ */
2523
+ async findOrphanWorktrees(repository) {
2524
+ return await query(this.db, `
2525
+ SELECT w.id, w.path, w.pr_number, w.repository,
2526
+ r.id AS reviewId
2527
+ FROM worktrees w
2528
+ LEFT JOIN worktree_pool wp ON w.id = wp.id
2529
+ LEFT JOIN reviews r ON r.pr_number = w.pr_number AND r.repository = w.repository COLLATE NOCASE
2530
+ WHERE w.repository = ? COLLATE NOCASE AND wp.id IS NULL
2531
+ `, [repository]);
2532
+ }
2533
+
2534
+ /**
2535
+ * Mark a pool worktree as in_use.
2536
+ */
2537
+ async markInUse(id, prNumber) {
2538
+ const now = new Date().toISOString();
2539
+ await run(this.db, `UPDATE worktree_pool SET status = 'in_use', current_pr_number = ?, last_switched_at = ?, current_review_id = NULL WHERE id = ?`, [prNumber, now, id]);
2540
+ }
2541
+
2542
+ /**
2543
+ * Mark a pool worktree as available (evictable).
2544
+ * Clears current_review_id to release ownership.
2545
+ */
2546
+ async markAvailable(id) {
2547
+ await run(this.db, `UPDATE worktree_pool SET status = 'available', current_review_id = NULL WHERE id = ?`, [id]);
2548
+ }
2549
+
2550
+ /**
2551
+ * Mark a pool worktree as switching (transitional state during PR switch).
2552
+ */
2553
+ async markSwitching(id) {
2554
+ await run(this.db, `UPDATE worktree_pool SET status = 'switching' WHERE id = ?`, [id]);
2555
+ }
2556
+
2557
+ /**
2558
+ * Update last_fetched_at timestamp.
2559
+ */
2560
+ async updateLastFetched(id) {
2561
+ const now = new Date().toISOString();
2562
+ await run(this.db, `UPDATE worktree_pool SET last_fetched_at = ? WHERE id = ?`, [now, id]);
2563
+ }
2564
+
2565
+ /**
2566
+ * Find idle pool worktrees for a repository (status = 'available').
2567
+ */
2568
+ async findIdleForRepo(repository) {
2569
+ return await query(this.db, `
2570
+ SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
2571
+ FROM worktree_pool
2572
+ WHERE repository = ? COLLATE NOCASE AND status = 'available'
2573
+ ORDER BY last_switched_at ASC NULLS FIRST
2574
+ `, [repository]);
2575
+ }
2576
+
2577
+ /**
2578
+ * Find all pool worktrees for a repository.
2579
+ */
2580
+ async findAllForRepo(repository) {
2581
+ return await query(this.db, `
2582
+ SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
2583
+ FROM worktree_pool
2584
+ WHERE repository = ? COLLATE NOCASE
2585
+ `, [repository]);
2586
+ }
2587
+
2588
+ /**
2589
+ * Find all pool worktrees for background fetch (excludes 'switching' status).
2590
+ * Ordered by last_fetched_at ASC NULLS FIRST (coldest first).
2591
+ */
2592
+ async findAllForFetch(repository) {
2593
+ return await query(this.db, `
2594
+ SELECT id, path, last_fetched_at, status
2595
+ FROM worktree_pool
2596
+ WHERE repository = ? COLLATE NOCASE AND status != 'switching'
2597
+ ORDER BY last_fetched_at ASC NULLS FIRST
2598
+ `, [repository]);
2599
+ }
2600
+
2601
+ /**
2602
+ * Check if a worktree ID belongs to the pool.
2603
+ */
2604
+ async isPoolWorktree(id) {
2605
+ const row = await queryOne(this.db, `SELECT id FROM worktree_pool WHERE id = ?`, [id]);
2606
+ return !!row;
2607
+ }
2608
+
2609
+ /**
2610
+ * Get a pool entry by worktree ID, returning the full row including status.
2611
+ * @param {string} id - Pool worktree ID
2612
+ * @returns {Promise<Object|undefined>} Pool entry or undefined if not found
2613
+ */
2614
+ async getPoolEntry(id) {
2615
+ return await queryOne(this.db, `
2616
+ SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
2617
+ FROM worktree_pool WHERE id = ?
2618
+ `, [id]);
2619
+ }
2620
+
2621
+ /**
2622
+ * Delete a pool entry.
2623
+ */
2624
+ async delete(id) {
2625
+ await run(this.db, `DELETE FROM worktree_pool WHERE id = ?`, [id]);
2626
+ }
2627
+
2628
+ /**
2629
+ * Find a pool worktree currently assigned to a review.
2630
+ * @param {number} reviewId - The review ID
2631
+ * @returns {Promise<{id: string}|undefined>} Pool entry with worktree ID, or undefined
2632
+ */
2633
+ async findByReviewId(reviewId) {
2634
+ return await queryOne(this.db, `SELECT id FROM worktree_pool WHERE current_review_id = ? AND status = 'in_use'`, [reviewId]);
2635
+ }
2636
+
2637
+ /**
2638
+ * Set the current review ID for a pool worktree (persistent ownership).
2639
+ * @param {string} id - Pool worktree ID
2640
+ * @param {number|null} reviewId - Review ID that owns the worktree
2641
+ */
2642
+ async setCurrentReviewId(id, reviewId) {
2643
+ await run(this.db, `UPDATE worktree_pool SET current_review_id = ? WHERE id = ?`, [reviewId, id]);
2644
+ }
2645
+
2646
+ /**
2647
+ * Atomically reserve a new pool slot if capacity allows.
2648
+ * Uses BEGIN IMMEDIATE to serialize against concurrent callers, preventing
2649
+ * two requests from both observing spare capacity and both creating slots.
2650
+ *
2651
+ * Inserts a placeholder row with status 'creating'. The caller must:
2652
+ * - On success: call markInUse() to transition to 'in_use'
2653
+ * - On failure: call deleteReservation() to remove the placeholder
2654
+ *
2655
+ * @param {string} id - Pool worktree ID (e.g., 'pool-abc')
2656
+ * @param {string} repository - Repository in "owner/repo" format
2657
+ * @param {number} poolSize - Maximum pool slots for this repository
2658
+ * @returns {Promise<boolean>} true if the slot was reserved, false if at capacity
2659
+ */
2660
+ async reserveSlot(id, repository, poolSize) {
2661
+ const reserveTx = this.db.transaction(() => {
2662
+ const row = this.db.prepare(
2663
+ `SELECT COUNT(*) as count FROM worktree_pool WHERE repository = ? COLLATE NOCASE`
2664
+ ).get(repository);
2665
+ const currentCount = row ? row.count : 0;
2666
+ if (currentCount >= poolSize) {
2667
+ return false;
2668
+ }
2669
+ const now = new Date().toISOString();
2670
+ // Use a unique placeholder path to satisfy the UNIQUE constraint.
2671
+ // finalizeReservation will replace it with the real path.
2672
+ const placeholderPath = `__creating__${id}`;
2673
+ this.db.prepare(
2674
+ `INSERT INTO worktree_pool (id, repository, path, status, created_at) VALUES (?, ?, ?, 'creating', ?)`
2675
+ ).run(id, repository, placeholderPath, now);
2676
+ return true;
2677
+ });
2678
+ return reserveTx.immediate();
2679
+ }
2680
+
2681
+ /**
2682
+ * Finalize a reserved pool slot after successful worktree creation.
2683
+ * Updates the placeholder row with the actual path and marks it in_use.
2684
+ *
2685
+ * @param {string} id - Pool worktree ID
2686
+ * @param {string} path - Absolute filesystem path to the created worktree
2687
+ * @param {number} prNumber - PR number to assign
2688
+ */
2689
+ async finalizeReservation(id, path, prNumber) {
2690
+ const now = new Date().toISOString();
2691
+ await run(this.db, `UPDATE worktree_pool SET path = ?, status = 'in_use', current_pr_number = ?, last_switched_at = ? WHERE id = ? AND status = 'creating'`, [path, prNumber, now, id]);
2692
+ }
2693
+
2694
+ /**
2695
+ * Delete a reserved pool slot placeholder (cleanup on creation failure).
2696
+ *
2697
+ * @param {string} id - Pool worktree ID to remove
2698
+ */
2699
+ async deleteReservation(id) {
2700
+ await run(this.db, `DELETE FROM worktree_pool WHERE id = ? AND status = 'creating'`, [id]);
2701
+ }
2702
+
2703
+ /**
2704
+ * Atomically find and claim a pool worktree already assigned to a PR.
2705
+ * Uses BEGIN IMMEDIATE to serialize against concurrent callers.
2706
+ *
2707
+ * @param {number} prNumber - PR number to find
2708
+ * @param {string} repository - Repository in "owner/repo" format
2709
+ * @returns {Promise<Object|null>} Claimed pool entry or null if not found
2710
+ */
2711
+ async claimByPR(prNumber, repository) {
2712
+ const claimTx = this.db.transaction(() => {
2713
+ const entry = this.db.prepare(`
2714
+ SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
2715
+ FROM worktree_pool
2716
+ WHERE current_pr_number = ? AND repository = ? COLLATE NOCASE
2717
+ AND status IN ('in_use', 'available')
2718
+ `).get(prNumber, repository);
2719
+ if (entry) {
2720
+ const now = new Date().toISOString();
2721
+ this.db.prepare(
2722
+ `UPDATE worktree_pool SET status = 'in_use', current_pr_number = ?, last_switched_at = ?, current_review_id = NULL WHERE id = ?`
2723
+ ).run(prNumber, now, entry.id);
2724
+ }
2725
+ return entry || null;
2726
+ });
2727
+ return claimTx.immediate();
2728
+ }
2729
+
2730
+ /**
2731
+ * Atomically find and claim the LRU available pool worktree for a repository.
2732
+ * Uses BEGIN IMMEDIATE to serialize against concurrent callers.
2733
+ * Marks the claimed entry as 'switching' so no other caller can grab it.
2734
+ *
2735
+ * @param {string} repository - Repository in "owner/repo" format
2736
+ * @returns {Promise<Object|null>} Claimed pool entry or null if none available
2737
+ */
2738
+ async claimAvailable(repository) {
2739
+ const claimTx = this.db.transaction(() => {
2740
+ const entry = this.db.prepare(`
2741
+ SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
2742
+ FROM worktree_pool
2743
+ WHERE repository = ? COLLATE NOCASE AND status = 'available'
2744
+ ORDER BY last_switched_at ASC NULLS FIRST
2745
+ LIMIT 1
2746
+ `).get(repository);
2747
+ if (entry) {
2748
+ this.db.prepare(
2749
+ `UPDATE worktree_pool SET status = 'switching' WHERE id = ?`
2750
+ ).run(entry.id);
2751
+ }
2752
+ return entry || null;
2753
+ });
2754
+ return claimTx.immediate();
2755
+ }
2756
+
2757
+ /**
2758
+ * Reset stale pool entries on startup while preserving valid ownership.
2759
+ * Entries are considered stale if: no review owner, interrupted switching,
2760
+ * or the owning review has been deleted.
2761
+ * @returns {Array<{id: string, current_review_id: number}>} Preserved entries for in-memory rehydration
2762
+ */
2763
+ async resetStaleAndPreserve() {
2764
+ // Delete placeholder entries that were mid-creation when the server stopped.
2765
+ // These have no valid path or worktree on disk — they cannot be recovered.
2766
+ await run(this.db, `DELETE FROM worktree_pool WHERE status = 'creating'`);
2767
+
2768
+ // Reset entries that are stale: no review owner, interrupted switching, or review deleted
2769
+ await run(this.db, `
2770
+ UPDATE worktree_pool SET status = 'available', current_review_id = NULL
2771
+ WHERE status != 'available' AND (
2772
+ current_review_id IS NULL
2773
+ OR status = 'switching'
2774
+ OR current_review_id NOT IN (SELECT id FROM reviews)
2775
+ )
2776
+ `);
2777
+ // Return preserved entries for in-memory rehydration
2778
+ return await query(this.db, `
2779
+ SELECT id, current_review_id FROM worktree_pool
2780
+ WHERE status = 'in_use' AND current_review_id IS NOT NULL
2781
+ `);
2782
+ }
2783
+ }
2784
+
2118
2785
  /**
2119
2786
  * RepoSettingsRepository class for managing per-repository AI settings
2120
2787
  */
@@ -2134,9 +2801,9 @@ class RepoSettingsRepository {
2134
2801
  */
2135
2802
  async getRepoSettings(repository) {
2136
2803
  const row = await queryOne(this.db, `
2137
- SELECT id, repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, auto_branch_review, created_at, updated_at
2804
+ SELECT id, repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, auto_branch_review, pool_size, pool_fetch_interval_minutes, load_skills, created_at, updated_at
2138
2805
  FROM repo_settings
2139
- WHERE repository = ? COLLATE NOCASE
2806
+ WHERE repository = ?
2140
2807
  `, [repository]);
2141
2808
 
2142
2809
  return row || null;
@@ -2149,7 +2816,7 @@ class RepoSettingsRepository {
2149
2816
  */
2150
2817
  async getLocalPath(repository) {
2151
2818
  const row = await queryOne(this.db, `
2152
- SELECT local_path FROM repo_settings WHERE repository = ? COLLATE NOCASE
2819
+ SELECT local_path FROM repo_settings WHERE repository = ?
2153
2820
  `, [repository]);
2154
2821
 
2155
2822
  return row ? row.local_path : null;
@@ -2173,7 +2840,7 @@ class RepoSettingsRepository {
2173
2840
  await run(this.db, `
2174
2841
  UPDATE repo_settings
2175
2842
  SET local_path = ?, updated_at = ?
2176
- WHERE repository = ? COLLATE NOCASE
2843
+ WHERE repository = ?
2177
2844
  `, [localPath, now, repository]);
2178
2845
  } else {
2179
2846
  // Insert new settings with just local_path
@@ -2191,7 +2858,7 @@ class RepoSettingsRepository {
2191
2858
  * @returns {Promise<Object>} Saved settings object
2192
2859
  */
2193
2860
  async saveRepoSettings(repository, settings) {
2194
- const { default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path } = settings;
2861
+ const { default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, pool_size, pool_fetch_interval_minutes, load_skills } = settings;
2195
2862
  const now = new Date().toISOString();
2196
2863
 
2197
2864
  // Check if settings already exist
@@ -2208,8 +2875,11 @@ class RepoSettingsRepository {
2208
2875
  default_tab = ?,
2209
2876
  default_chat_instructions = ?,
2210
2877
  local_path = ?,
2878
+ pool_size = ?,
2879
+ pool_fetch_interval_minutes = ?,
2880
+ load_skills = ?,
2211
2881
  updated_at = ?
2212
- WHERE repository = ? COLLATE NOCASE
2882
+ WHERE repository = ?
2213
2883
  `, [
2214
2884
  default_instructions !== undefined ? default_instructions : existing.default_instructions,
2215
2885
  default_provider !== undefined ? default_provider : existing.default_provider,
@@ -2218,6 +2888,9 @@ class RepoSettingsRepository {
2218
2888
  default_tab !== undefined ? default_tab : existing.default_tab,
2219
2889
  default_chat_instructions !== undefined ? default_chat_instructions : existing.default_chat_instructions,
2220
2890
  local_path !== undefined ? local_path : existing.local_path,
2891
+ pool_size !== undefined ? pool_size : existing.pool_size,
2892
+ pool_fetch_interval_minutes !== undefined ? pool_fetch_interval_minutes : existing.pool_fetch_interval_minutes,
2893
+ load_skills !== undefined ? load_skills : existing.load_skills,
2221
2894
  now,
2222
2895
  repository
2223
2896
  ]);
@@ -2231,14 +2904,17 @@ class RepoSettingsRepository {
2231
2904
  default_tab: default_tab !== undefined ? default_tab : existing.default_tab,
2232
2905
  default_chat_instructions: default_chat_instructions !== undefined ? default_chat_instructions : existing.default_chat_instructions,
2233
2906
  local_path: local_path !== undefined ? local_path : existing.local_path,
2907
+ pool_size: pool_size !== undefined ? pool_size : existing.pool_size,
2908
+ pool_fetch_interval_minutes: pool_fetch_interval_minutes !== undefined ? pool_fetch_interval_minutes : existing.pool_fetch_interval_minutes,
2909
+ load_skills: load_skills !== undefined ? load_skills : existing.load_skills,
2234
2910
  updated_at: now
2235
2911
  };
2236
2912
  } else {
2237
2913
  // Insert new settings
2238
2914
  const result = await run(this.db, `
2239
- INSERT INTO repo_settings (repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, created_at, updated_at)
2240
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2241
- `, [repository, default_instructions || null, default_provider || null, default_model || null, default_council_id || null, default_tab || null, default_chat_instructions || null, local_path || null, now, now]);
2915
+ INSERT INTO repo_settings (repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, pool_size, pool_fetch_interval_minutes, load_skills, created_at, updated_at)
2916
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2917
+ `, [repository, default_instructions || null, default_provider || null, default_model || null, default_council_id || null, default_tab || null, default_chat_instructions || null, local_path || null, pool_size ?? null, pool_fetch_interval_minutes ?? null, load_skills ?? null, now, now]);
2242
2918
 
2243
2919
  return {
2244
2920
  id: result.lastID,
@@ -2250,12 +2926,61 @@ class RepoSettingsRepository {
2250
2926
  default_tab: default_tab || null,
2251
2927
  default_chat_instructions: default_chat_instructions || null,
2252
2928
  local_path: local_path || null,
2929
+ pool_size: pool_size ?? null,
2930
+ pool_fetch_interval_minutes: pool_fetch_interval_minutes ?? null,
2931
+ load_skills: load_skills ?? null,
2253
2932
  created_at: now,
2254
2933
  updated_at: now
2255
2934
  };
2256
2935
  }
2257
2936
  }
2258
2937
 
2938
+ /**
2939
+ * Atomically attempt to claim the background fetch lease for a repository.
2940
+ * Uses SQLite UPSERT with a conditional WHERE clause to avoid TOCTOU races
2941
+ * between instances sharing the same database. Creates a repo_settings row
2942
+ * if one doesn't exist yet (config-only repos).
2943
+ * @param {string} repository - Repository in owner/repo format
2944
+ * @param {number} [staleGuardMs=600000] - Consider a fetch stale after this many ms (default 10 min)
2945
+ * @returns {Promise<boolean>} true if the lease was successfully claimed
2946
+ */
2947
+ async tryClaimFetch(repository, staleGuardMs = 600000) {
2948
+ const now = new Date().toISOString();
2949
+ const staleThreshold = new Date(Date.now() - staleGuardMs).toISOString();
2950
+ const result = await run(this.db,
2951
+ `INSERT INTO repo_settings (repository, pool_fetch_started_at, created_at, updated_at)
2952
+ VALUES (?, ?, ?, ?)
2953
+ ON CONFLICT(repository) DO UPDATE SET pool_fetch_started_at = excluded.pool_fetch_started_at,
2954
+ pool_fetch_finished_at = NULL
2955
+ WHERE pool_fetch_started_at IS NULL
2956
+ OR pool_fetch_finished_at >= pool_fetch_started_at
2957
+ OR pool_fetch_started_at < ?`,
2958
+ [repository, now, now, now, staleThreshold]
2959
+ );
2960
+ return result.changes > 0;
2961
+ }
2962
+
2963
+ /**
2964
+ * Refresh the fetch lease timestamp for a repository.
2965
+ * Called after each successful worktree fetch to extend the stale guard window
2966
+ * so the 10-minute default only needs to outlive a single stalled fetch rather
2967
+ * than the entire serial loop across all worktrees.
2968
+ * @param {string} repository - Repository in owner/repo format
2969
+ */
2970
+ async refreshFetchLease(repository) {
2971
+ const now = new Date().toISOString();
2972
+ await run(this.db, `UPDATE repo_settings SET pool_fetch_started_at = ? WHERE repository = ?`, [now, repository]);
2973
+ }
2974
+
2975
+ /**
2976
+ * Mark a repo-level background fetch as finished.
2977
+ * @param {string} repository - Repository in owner/repo format
2978
+ */
2979
+ async markFetchFinished(repository) {
2980
+ const now = new Date().toISOString();
2981
+ await run(this.db, `UPDATE repo_settings SET pool_fetch_finished_at = ? WHERE repository = ?`, [now, repository]);
2982
+ }
2983
+
2259
2984
  /**
2260
2985
  * Delete settings for a repository
2261
2986
  * @param {string} repository - Repository in owner/repo format
@@ -2263,7 +2988,7 @@ class RepoSettingsRepository {
2263
2988
  */
2264
2989
  async deleteRepoSettings(repository) {
2265
2990
  const result = await run(this.db, `
2266
- DELETE FROM repo_settings WHERE repository = ? COLLATE NOCASE
2991
+ DELETE FROM repo_settings WHERE repository = ?
2267
2992
  `, [repository]);
2268
2993
 
2269
2994
  return result.changes > 0;
@@ -4234,6 +4959,7 @@ module.exports = {
4234
4959
  CURRENT_SCHEMA_VERSION,
4235
4960
  getDbPath,
4236
4961
  WorktreeRepository,
4962
+ WorktreePoolRepository,
4237
4963
  RepoSettingsRepository,
4238
4964
  ReviewRepository,
4239
4965
  CommentRepository,
@@ -4243,5 +4969,7 @@ module.exports = {
4243
4969
  CouncilRepository,
4244
4970
  ContextFileRepository,
4245
4971
  generateWorktreeId,
4246
- migrateExistingWorktrees
4972
+ migrateExistingWorktrees,
4973
+ // Exported for testing only
4974
+ _MIGRATIONS: MIGRATIONS
4247
4975
  };