@karmaniverous/jeeves-watcher 0.19.0-3 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -265,7 +265,7 @@ async function startFromConfig(configPath, descriptor) {
265
265
  const config = await loadConfig(resolvedPath);
266
266
  // Dynamic import breaks the circular dependency between this module
267
267
  // and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
268
- const { JeevesWatcher } = await import('./index-CD0v-oGg.js');
268
+ const { JeevesWatcher } = await import('./index-C_bf9c9m.js');
269
269
  const app = new JeevesWatcher(config, resolvedPath, descriptor);
270
270
  installShutdownHandlers(() => app.stop());
271
271
  await app.start();
@@ -636,6 +636,7 @@ function computeDelayMs(attempt, baseDelayMs, maxDelayMs, jitter = 0) {
636
636
  */
637
637
  async function retry(fn, options) {
638
638
  const attempts = Math.max(1, options.attempts);
639
+ const shouldRetry = options.shouldRetry ?? (() => true);
639
640
  let lastError;
640
641
  for (let attempt = 1; attempt <= attempts; attempt++) {
641
642
  try {
@@ -644,7 +645,7 @@ async function retry(fn, options) {
644
645
  catch (error) {
645
646
  lastError = error;
646
647
  const isLast = attempt >= attempts;
647
- if (isLast)
648
+ if (isLast || !shouldRetry(error))
648
649
  break;
649
650
  const delayMs = computeDelayMs(attempt, options.baseDelayMs, options.maxDelayMs, options.jitter);
650
651
  options.onRetry?.({ attempt, attempts, delayMs, error });
@@ -656,10 +657,28 @@ async function retry(fn, options) {
656
657
 
657
658
  /**
658
659
  * @module vcs/gitExec
659
- * Shared git execution utilities.
660
+ * Shared git execution utilities: the pinned argv prefix, timeouts, error
661
+ * field extraction and classification, and path comparison helpers.
660
662
  */
661
663
  /** Promisified execFile for git commands. */
662
664
  const execFileAsync = promisify(execFile);
665
+ /**
666
+ * Leading git arguments applied to every VCS-owned git invocation.
667
+ *
668
+ * `-c core.longpaths=true` lets Windows checkouts handle paths beyond the
669
+ * legacy MAX_PATH limit (260 chars) without requiring a machine-wide
670
+ * `core.longpaths` git config or registry change. See #249.
671
+ */
672
+ const GIT_BASE_ARGS = ['-c', 'core.longpaths=true'];
673
+ /**
674
+ * Build a git argv array with {@link GIT_BASE_ARGS} prefixed.
675
+ *
676
+ * @param args - The subcommand and its arguments (e.g. `'commit', '-m', msg`).
677
+ * @returns The full argv array, base args first.
678
+ */
679
+ function gitArgs(...args) {
680
+ return [...GIT_BASE_ARGS, ...args];
681
+ }
663
682
  /** Standard timeout (ms) for most git operations. */
664
683
  const GIT_TIMEOUT_STANDARD = 30_000;
665
684
  /** Extended timeout (ms) for cherry-pick operations. */
@@ -681,49 +700,37 @@ function getExecErrorFields(error) {
681
700
  return { message: error.message || '', stderr, stdout };
682
701
  }
683
702
  /**
684
- * Stage files via stdin to avoid ENAMETOOLONG on Windows.
703
+ * Read the `code` property of an unknown rejection value, if present.
704
+ * execFile rejects with the child's numeric exit status in `code`; fs calls
705
+ * reject with a string errno code (e.g. `'ENOENT'`).
706
+ *
707
+ * @param error - The caught value.
708
+ * @returns The code, or undefined if the value has none.
709
+ */
710
+ function getErrorCode(error) {
711
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
712
+ return undefined;
713
+ }
714
+ const { code } = error;
715
+ return typeof code === 'string' || typeof code === 'number'
716
+ ? code
717
+ : undefined;
718
+ }
719
+ /**
720
+ * Check whether an error is caused by `index.lock` contention.
685
721
  *
686
- * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
687
- * through stdin (NUL-delimited) instead of passed as command-line
688
- * arguments. This sidesteps the Windows CreateProcessW 32 767-char
689
- * limit that triggers ENAMETOOLONG when batches contain many files
690
- * with long absolute paths.
722
+ * Requires the literal `index.lock` in the message or stderr: a bare
723
+ * `EEXIST` (any file that already exists) is not lock contention and must
724
+ * not be retried as if it were. Only Error instances qualify.
691
725
  *
692
- * @param files - Absolute paths of files to stage.
693
- * @param cwd - Repository root (working directory for git).
694
- * @param timeoutMs - Kill the child process after this many milliseconds. Default: 30000.
726
+ * @param error - The caught value.
727
+ * @returns true if the error names `index.lock`.
695
728
  */
696
- function gitAddViaStdin(files, cwd, timeoutMs = 30_000) {
697
- return new Promise((resolve, reject) => {
698
- let timedOut = false;
699
- const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
700
- clearTimeout(timer);
701
- if (timedOut)
702
- return;
703
- if (error)
704
- reject(error);
705
- else
706
- resolve();
707
- });
708
- const timer = setTimeout(() => {
709
- timedOut = true;
710
- child.kill();
711
- reject(new Error(`git add timed out after ${String(timeoutMs)}ms`));
712
- }, timeoutMs);
713
- if (!child.stdin) {
714
- clearTimeout(timer);
715
- reject(new Error('Failed to open stdin for git add'));
716
- return;
717
- }
718
- // Suppress EPIPE — expected if git exits before we finish writing
719
- child.stdin.on('error', (err) => {
720
- if (err.code !== 'EPIPE') {
721
- clearTimeout(timer);
722
- reject(err);
723
- }
724
- });
725
- child.stdin.end(files.join('\0'));
726
- });
729
+ function isIndexLockError(error) {
730
+ if (!(error instanceof Error))
731
+ return false;
732
+ const { message, stderr } = getExecErrorFields(error);
733
+ return message.includes('index.lock') || stderr.includes('index.lock');
727
734
  }
728
735
  /**
729
736
  * Normalize a path for case-insensitive comparison on Windows.
@@ -736,6 +743,17 @@ function gitAddViaStdin(files, cwd, timeoutMs = 30_000) {
736
743
  function normalizePathCase(p, platform = process.platform) {
737
744
  return platform === 'win32' ? p.toLowerCase() : p;
738
745
  }
746
+ /**
747
+ * Build a comparison key for an absolute path: resolved, forward slashes,
748
+ * and lowercased on Windows.
749
+ *
750
+ * @param p - The path.
751
+ * @param platform - The platform to check against (default: `process.platform`).
752
+ * @returns The comparison key.
753
+ */
754
+ function pathKey(p, platform = process.platform) {
755
+ return normalizePathCase(normalizeSlashes(resolve(p)), platform);
756
+ }
739
757
  /**
740
758
  * Find the longest-prefix-match root for a normalized path.
741
759
  * Roots must be sorted longest-first for correct nested matching.
@@ -1639,4 +1657,4 @@ const watcherDescriptor = {
1639
1657
  const program = createServiceCli(watcherDescriptor);
1640
1658
  program.parse();
1641
1659
 
1642
- export { CONFIG_WATCH_VALID_SCOPES as C, GIT_TIMEOUT_STANDARD as G, VALID_REINDEX_SCOPES as V, normalizeError as a, GIT_TIMEOUT_CHERRY_PICK as b, GIT_TIMEOUT_PUSH as c, gitAddViaStdin as d, execFileAsync as e, normalizePathCase as f, getExecErrorFields as g, findRootForPath as h, GitignoreFilter as i, executeReindex as j, isPathWatched as k, getWatchRootBases as l, mergeInferenceRules as m, normalizeSlashes as n, loadConfig as o, startFromConfig as p, retry as r, sleep as s };
1660
+ export { CONFIG_WATCH_VALID_SCOPES as C, GIT_TIMEOUT_STANDARD as G, VALID_REINDEX_SCOPES as V, normalizeError as a, getErrorCode as b, GIT_TIMEOUT_CHERRY_PICK as c, GIT_TIMEOUT_PUSH as d, execFileAsync as e, normalizePathCase as f, gitArgs as g, findRootForPath as h, isIndexLockError as i, GitignoreFilter as j, executeReindex as k, isPathWatched as l, mergeInferenceRules as m, normalizeSlashes as n, getWatchRootBases as o, pathKey as p, loadConfig as q, retry as r, sleep as s, startFromConfig as t };
package/dist/index.d.ts CHANGED
@@ -1227,6 +1227,25 @@ declare class EventQueue {
1227
1227
  private maybeResolveIdleWorkers;
1228
1228
  }
1229
1229
 
1230
+ /**
1231
+ * @module vcs/CommitCircuitBreaker
1232
+ * Per-root circuit breaker for the VCS commit pipeline. Counts consecutive
1233
+ * failures, trips at a threshold, and allows one half-open attempt per
1234
+ * cooldown. It holds no files: callers retain pending work while it is
1235
+ * tripped. See #249.
1236
+ */
1237
+ /** Serializable breaker state. */
1238
+ interface CircuitBreakerSnapshot {
1239
+ /** Consecutive commit failures since the last success. */
1240
+ consecutiveFailures: number;
1241
+ /** Whether the breaker is tripped. */
1242
+ tripped: boolean;
1243
+ /** ISO timestamp of the current trip, or null. */
1244
+ trippedAt: string | null;
1245
+ /** Message of the most recent failure, or null. */
1246
+ lastError: string | null;
1247
+ }
1248
+
1230
1249
  /**
1231
1250
  * @module vcs/CommitMessageGenerator
1232
1251
  * Generates AI-powered commit messages via the Anthropic Messages API.
@@ -1272,6 +1291,11 @@ interface PushError {
1272
1291
  * Per-root VCS manager for git-backed content versioning.
1273
1292
  */
1274
1293
 
1294
+ /** Per-root circuit breaker state, exposed via /vcs/status. */
1295
+ interface VcsBreakerState extends CircuitBreakerSnapshot {
1296
+ /** Files queued or in flight, not yet committed. */
1297
+ pendingCount: number;
1298
+ }
1275
1299
  /**
1276
1300
  * Per-root VCS manager for git-backed content versioning.
1277
1301
  *
@@ -1280,7 +1304,9 @@ interface PushError {
1280
1304
  * (with optional AI-generated messages), and optionally pushed to a remote.
1281
1305
  *
1282
1306
  * Concurrency: only one commit is in-flight at a time per root. Index.lock
1283
- * contention is handled with exponential backoff retries (D10).
1307
+ * contention is handled with exponential backoff retries (D10). Failed
1308
+ * batches are always retained; a circuit breaker spaces out retries of a
1309
+ * persistently failing root (#249).
1284
1310
  */
1285
1311
  declare class VcsManager {
1286
1312
  readonly config: VcsConfig;
@@ -1289,13 +1315,15 @@ declare class VcsManager {
1289
1315
  private readonly accessToken;
1290
1316
  private readonly logger;
1291
1317
  private readonly commitMessageBuilder;
1318
+ private readonly breaker;
1292
1319
  private readonly pending;
1293
1320
  private readonly pendingReversions;
1294
1321
  private readonly _pushErrors;
1295
1322
  private readonly squashManager;
1296
1323
  private throttleTimer;
1324
+ private breakerRetryTimer;
1297
1325
  private commitInFlight;
1298
- private consecutiveCommitFailures;
1326
+ private inFlightFileCount;
1299
1327
  private started;
1300
1328
  private paused;
1301
1329
  private isBaseline;
@@ -1303,12 +1331,15 @@ declare class VcsManager {
1303
1331
  constructor(rootPath: string, config: VcsConfig, logger: pino.Logger, commitMessageGenerator?: CommitMessageGenerator, remoteUrl?: string, accessToken?: string);
1304
1332
  get lastPushTime(): string | null;
1305
1333
  get pushErrors(): readonly PushError[];
1334
+ /** Current circuit breaker state and backlog, for /vcs/status. */
1335
+ get breakerState(): VcsBreakerState;
1306
1336
  /**
1307
1337
  * Begin accepting file changes. Sets the manager to started state and
1308
1338
  * starts the squash manager if configured.
1309
1339
  *
1310
1340
  * Performs startup orphan detection/recovery before starting the squash
1311
- * manager, ensuring the repo is on the configured branch.
1341
+ * manager, ensuring the repo is on the configured branch, and reports
1342
+ * (never clears) an abandoned cherry-pick, rebase, or merge.
1312
1343
  *
1313
1344
  * Baseline mode (commit prefix `"baseline:"`) remains active until
1314
1345
  * {@link endBaseline} is called by the coordinator after the initial scan.
@@ -1321,7 +1352,10 @@ declare class VcsManager {
1321
1352
  endBaseline(): void;
1322
1353
  /**
1323
1354
  * Add a file to the pending set and start the throttle timer.
1324
- * If the pending set exceeds maxBatchSize, flush immediately.
1355
+ * If the pending set reaches maxBatchSize, commit a batch immediately.
1356
+ *
1357
+ * The circuit breaker is intentionally not reset here: recovery is
1358
+ * time-based, so a busy root cannot hammer a deterministic failure.
1325
1359
  *
1326
1360
  * @param filePath - Absolute path of the changed file.
1327
1361
  */
@@ -1333,8 +1367,7 @@ declare class VcsManager {
1333
1367
  */
1334
1368
  addPendingReversion(reversion: PendingReversion): void;
1335
1369
  /**
1336
- * Stage a file deletion and add to pending set.
1337
- * git add handles deleted files when the file is gone from disk.
1370
+ * Queue a deleted file. Staging records the deletion if git tracks it.
1338
1371
  *
1339
1372
  * @param filePath - Absolute path of the deleted file.
1340
1373
  */
@@ -1352,7 +1385,7 @@ declare class VcsManager {
1352
1385
  */
1353
1386
  resume(): void;
1354
1387
  /**
1355
- * Flush all pending files immediately.
1388
+ * Commit all pending files now, in batches of at most maxBatchSize.
1356
1389
  */
1357
1390
  flush(): Promise<void>;
1358
1391
  /**
@@ -1360,32 +1393,46 @@ declare class VcsManager {
1360
1393
  */
1361
1394
  stop(): Promise<void>;
1362
1395
  /**
1363
- * Take up to N items from the pending set.
1396
+ * Add tracked-but-deleted paths (`git ls-files --deleted`) in this
1397
+ * manager's watch scope to pending, so the next commit (typically the
1398
+ * baseline) records deletions that happened while nothing was watching.
1399
+ * Called by the coordinator before flush/endBaseline on startup. See #249.
1400
+ *
1401
+ * @param isInScope - Returns true if the absolute path routes to this
1402
+ * manager (the coordinator's routing and watch-scope logic).
1364
1403
  */
1404
+ reconcileDeletions(isInScope: (absolutePath: string) => boolean): Promise<void>;
1405
+ /** Take up to maxBatchSize files from the pending set. */
1365
1406
  private takeBatch;
1366
- /**
1367
- * Clear the throttle timer.
1368
- */
1407
+ /** Chain a batch behind the in-flight commit, tracking it as backlog. */
1408
+ private enqueueBatch;
1409
+ /** Put files back in the pending set. Pending is a set, so this cannot grow it past the distinct paths seen. */
1410
+ private retain;
1369
1411
  private clearThrottle;
1370
1412
  /**
1371
1413
  * Start the throttle timer to fire flush() after commitThrottleMs.
1372
1414
  * If a timer is already running, do not reset it — this is throttle, not debounce.
1373
1415
  */
1374
1416
  private resetThrottle;
1417
+ private clearBreakerRetry;
1375
1418
  /**
1376
- * Check for and remove stale index.lock files before retrying.
1419
+ * Ensure a timer will flush pending files when the breaker's cooldown
1420
+ * elapses, without needing a new file-change event.
1377
1421
  */
1378
- private removeStaleLock;
1422
+ private scheduleBreakerRetry;
1379
1423
  /**
1380
- * Check whether a git error represents a "nothing to commit" state.
1381
- * Git outputs this message to stdout (not stderr) on exit code 1.
1424
+ * Commit a batch with index.lock retry, stale lock detection, pause
1425
+ * support, and the circuit breaker. A failed batch is always retained.
1382
1426
  */
1383
- private isNothingToCommitError;
1427
+ private commitBatch;
1384
1428
  /**
1385
- * Commit a batch of files to the git repo with index.lock retry,
1386
- * stale lock detection, circuit breaker, pause support, and re-queue cap.
1429
+ * One commit attempt: stage, skip if nothing is staged, commit, push.
1430
+ *
1431
+ * @returns true if a commit was created; false for a no-op.
1387
1432
  */
1388
- private commitBatch;
1433
+ private tryCommit;
1434
+ private onCommitSuccess;
1435
+ private onCommitFailure;
1389
1436
  }
1390
1437
 
1391
1438
  /**
@@ -1401,6 +1448,7 @@ declare class VcsCoordinator {
1401
1448
  private readonly managers;
1402
1449
  private readonly roots;
1403
1450
  private readonly logger;
1451
+ private readonly isInWatchScope;
1404
1452
  constructor(config: JeevesWatcherConfig, logger: pino.Logger);
1405
1453
  /**
1406
1454
  * Start all VcsManager instances.
@@ -1952,15 +2000,6 @@ interface CommitInfo {
1952
2000
  hash: string;
1953
2001
  date: Date;
1954
2002
  }
1955
- /**
1956
- * Manages scheduled squash retention for a single VCS root.
1957
- *
1958
- * Runs on a cron schedule (checked every 60s). When triggered, squashes
1959
- * commits older than the retention boundary (the tighter of maxAgeDays
1960
- * and maxVersions) into a single "historical baseline" orphan commit,
1961
- * then cherry-picks retained commits on top. Force-pushes to remote
1962
- * if configured (D13).
1963
- */
1964
2003
  /** Options for SquashManager construction beyond the required params. */
1965
2004
  interface SquashManagerOptions {
1966
2005
  /** Git branch name for squash operations. Default: "master". */
@@ -1974,6 +2013,15 @@ interface SquashManagerOptions {
1974
2013
  /** Access token for HTTPS push authentication. */
1975
2014
  accessToken?: string;
1976
2015
  }
2016
+ /**
2017
+ * Manages scheduled squash retention for a single VCS root.
2018
+ *
2019
+ * Runs on a cron schedule (checked every 60s). When triggered, squashes
2020
+ * commits older than the retention boundary (the tighter of maxAgeDays
2021
+ * and maxVersions) into a single "historical baseline" orphan commit,
2022
+ * then cherry-picks retained commits on top. Force-pushes to remote
2023
+ * if configured (D13).
2024
+ */
1977
2025
  declare class SquashManager {
1978
2026
  private readonly rootPath;
1979
2027
  private readonly retention;
@@ -2002,6 +2050,14 @@ declare class SquashManager {
2002
2050
  * to drain and suspend the commit pipeline during the squash.
2003
2051
  */
2004
2052
  runSquash(): Promise<SquashResult>;
2053
+ /**
2054
+ * Refuse to start a multi-step history rewrite while another git
2055
+ * operation holds the index or an abandoned cherry-pick, rebase, or merge
2056
+ * is present (#249: report only, never auto-clear).
2057
+ *
2058
+ * @returns The refusal reason, or undefined if the squash may proceed.
2059
+ */
2060
+ private checkRepoPreconditions;
2005
2061
  /**
2006
2062
  * Get all commits in chronological order (oldest first).
2007
2063
  */
@@ -2022,6 +2078,12 @@ declare class SquashManager {
2022
2078
  * Uses the configured branch name instead of dynamic detection (Bug 1).
2023
2079
  */
2024
2080
  private performSquash;
2081
+ /**
2082
+ * Best-effort cleanup after a failed squash. Aborts any half-finished
2083
+ * cherry-pick first, so a failure never leaves a sequencer behind (#249),
2084
+ * then returns to the configured branch and removes a leftover lock.
2085
+ */
2086
+ private cleanupFailedSquash;
2025
2087
  /**
2026
2088
  * Force push to remote after squash (history rewrite requires force).
2027
2089
  */