@niroai/niro 0.5.0 → 0.7.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.
@@ -19,6 +19,8 @@ const credentials = require("../lib/credentials");
19
19
  const prompt = require("../lib/prompt");
20
20
  const color = require("../lib/color");
21
21
  const folderSync = require("../lib/folderSync");
22
+ const git = require("../lib/git");
23
+ const tempProject = require("../lib/tempProject");
22
24
  const {
23
25
  SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
24
26
  IGNORED_DIRS, isConfigName, chunk,
@@ -31,10 +33,93 @@ const DEBOUNCE_MS = 2_000;
31
33
  // so a slower run would clobber a faster one's hash updates. A per-folder guard serializes them (a skipped
32
34
  // run is safe — the in-progress one re-hashes the whole tree and so already covers the skipped trigger).
33
35
  const syncInFlight = new Set();
36
+ // Folders that are NOT the one their temporary project currently reflects. Uploads from these are held
37
+ // back until the slot returns, so two branches cannot blend into one copy. Set by deltaSyncOnResume and
38
+ // honoured by the live file handler, so one check per sync pass covers both paths rather than asking
39
+ // the server on every save.
40
+ const notHolding = new Set();
41
+
42
+ /**
43
+ * How long the deletes for a folder must stay quiet before any of them is believed.
44
+ *
45
+ * Not a guess at how long a git checkout takes — the timer RESETS on every new delete, so a 90-second
46
+ * rebase holds the queue for 90 seconds and a three-file checkout holds it for five. This only has to
47
+ * outlast the gap between one file vanishing and the next.
48
+ */
49
+ const DELETE_SETTLE_MS = Number(process.env.NIRO_DELETE_SETTLE_MS) || 5_000;
50
+
51
+ /**
52
+ * Deletes waiting to be believed: resolved folder -> { paths:Set, timer }.
53
+ *
54
+ * A file disappearing is not evidence it was deleted. On 2026-07-31 a customer lost 494 files because
55
+ * a `git checkout` removed a tree, this handler saw the gap and reported every file gone, and git then
56
+ * wrote them all back — by which time the server had destroyed its copies. The client's own log shows
57
+ * the burst (249 consecutive deletes) followed immediately by re-uploads of the very same paths.
58
+ *
59
+ * So deletes are queued rather than sent, and ONE timer per folder is reset by every new delete. When
60
+ * the folder finally goes quiet, whatever is still missing is really gone and is sent; whatever came
61
+ * back was never deleted and is dropped.
62
+ *
63
+ * Deliberately NOT volume-based, and deliberately not "detect a git operation":
64
+ * - a threshold misses a small checkout that touches three files, which is just as wrong;
65
+ * - lock-file detection is git-only, and the same damage comes from `npm ci`, `make clean`, a Docker
66
+ * remount, Perforce (no local lock at all) or any other VCS.
67
+ * Waiting for quiet needs to know none of that. It is the same rule the server already applies before
68
+ * it rebuilds a graph.
69
+ *
70
+ * Holding a delete costs nothing: the graph only updates after its own quiet period, so a few seconds
71
+ * is invisible. A wrong delete costs the file AND its durable backup, at the moment the call lands.
72
+ */
73
+ const pendingDeletes = new Map();
74
+
75
+ /**
76
+ * Folders whose root we could not read, so the "cannot read" error is said ONCE rather than every
77
+ * 60 seconds for as long as the folder stays away. A watched folder that is renamed or unmounted is
78
+ * often left that way for days; repeating a red error every minute trains people to ignore the log,
79
+ * which is how the errors that DO matter get missed.
80
+ */
81
+ const unreadableRoots = new Set();
34
82
  // The state file is rewritten several times during a `niro takeover` (per-file hashes), so debounce
35
83
  // before reconciling the watch set. Also the interval for the mtime backstop re-scan below.
36
84
  const STATE_RELOAD_DEBOUNCE_MS = 1_500;
37
85
  const RESCAN_INTERVAL_MS = 60_000;
86
+ /** Ceiling for the backstop's exponential backoff. Long enough to stop hammering a broken server,
87
+ * short enough that a recovered one is picked up within half an hour without anyone intervening. */
88
+ const BACKSTOP_BACKOFF_MAX_MS = 30 * 60_000;
89
+
90
+ /**
91
+ * Per-folder retry state for the backstop re-scan: { failures, nextAttemptAt }.
92
+ *
93
+ * The backstop used to fire every 60s regardless of how many times it had just failed, with no
94
+ * backoff, no cap and no notion that anything was wrong. When the server broke on 8-9 August it
95
+ * minted a fresh sync session every single tick — roughly 1,400 failed requests a day, for two days,
96
+ * per watched folder. The bug was ours, but the client turned it into a flood.
97
+ */
98
+ const backstopBackoff = new Map();
99
+
100
+ /** Doubling delay, capped. failures=1 gives 2 minutes; the cap is reached after about six. */
101
+ function nextBackoffMs(failures) {
102
+ return Math.min(RESCAN_INTERVAL_MS * Math.pow(2, failures), BACKSTOP_BACKOFF_MAX_MS);
103
+ }
104
+
105
+ /** False while a folder is still serving a backoff from its last failure. */
106
+ function shouldAttemptBackstop(key, now = Date.now()) {
107
+ const state = backstopBackoff.get(key);
108
+ return !state || now >= state.nextAttemptAt;
109
+ }
110
+
111
+ /** @return the delay applied, so the caller can say how long it is waiting. */
112
+ function recordBackstopFailure(key, now = Date.now()) {
113
+ const failures = (backstopBackoff.get(key)?.failures || 0) + 1;
114
+ const delay = nextBackoffMs(failures);
115
+ backstopBackoff.set(key, { failures, nextAttemptAt: now + delay });
116
+ return delay;
117
+ }
118
+
119
+ /** @return true if this success ended a run of failures (so recovery is worth announcing). */
120
+ function recordBackstopSuccess(key) {
121
+ return backstopBackoff.delete(key);
122
+ }
38
123
 
39
124
  async function watch(pathArg, opts = {}) {
40
125
  const creds = credentials.requireCreds();
@@ -189,12 +274,7 @@ async function startWatch(targetDir, opts = {}) {
189
274
  // that drifted — the reliable, content-checked form of "also pick up changes we didn't get a live
190
275
  // event for". Re-reads fresh state, so a folder re-pointed by `niro takeover` or removed by `niro
191
276
  // release` mid-session is handled (or skipped) rather than synced to a stale gitId.
192
- const rescanTimer = setInterval(() => {
193
- const fresh = watchState.get(targetDir, api.currentBaseUrl());
194
- if (!fresh || !fresh.gitId) return; // folder released — nothing to sync
195
- deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true }).catch(err =>
196
- console.error(color.red(` Backstop re-scan failed for ${targetDir}: ${err.message}`)));
197
- }, RESCAN_INTERVAL_MS);
277
+ const rescanTimer = setInterval(() => { backstopTick(targetDir); }, RESCAN_INTERVAL_MS);
198
278
 
199
279
  // Start file watcher
200
280
  const debounceTimers = new Map();
@@ -244,40 +324,140 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
244
324
  if (syncInFlight.has(inFlightKey)) {
245
325
  // A sync (startup, live event, or backstop) is already running for this folder — skip to avoid the
246
326
  // whole-map fileHashes clobber race; the in-progress run re-hashes the whole tree and covers this trigger.
247
- return;
327
+ // Reported so the caller can tell "nothing to do" from "the server answered": this run never
328
+ // contacted it, so it is not evidence that anything is working.
329
+ return { skipped: true };
248
330
  }
249
331
  syncInFlight.add(inFlightKey);
250
332
  try {
251
333
  const quiet = opts.quiet === true; // periodic backstop re-scan runs silently unless it actually syncs
334
+
335
+ // Am I still the folder this temporary project reflects? Every folder taken over for one project
336
+ // shares the SAME repo id, so the server cannot tell us apart. If I keep uploading after the slot has
337
+ // moved, my branch blends into the copy that is reflecting someone else, and every answer drawn from
338
+ // it is a mixture — with nothing on screen to say so. Verified live with two worktrees.
339
+ //
340
+ // Nothing is changed or discarded here. The local record is left exactly as it is, so running
341
+ // `niro new-temp-project` here picks straight back up. This only declines to WRITE.
342
+ if (state.takeover && state.takeover.sourceProjectId) {
343
+ const heldBranch = await tempProject.currentBranch(state.projectId, gitId);
344
+ const myBranch = ((git.inspect(targetDir) || {}).branch) || state.branch;
345
+ // A null heldBranch means the server could not be asked. That must NEVER be read as "you lost the
346
+ // slot", or one network hiccup would stop a perfectly healthy folder syncing.
347
+ if (heldBranch && myBranch && heldBranch !== myBranch) {
348
+ if (!notHolding.has(inFlightKey)) {
349
+ notHolding.add(inFlightKey);
350
+ // TWO very different causes, and they need different advice. Another folder bound to the same
351
+ // copy is the stealing case. NO other folder means you simply switched branch in place, and
352
+ // saying "another folder holds it" there sends you hunting for a folder that does not exist —
353
+ // which is exactly what it did on a real repo the first time this ran.
354
+ const otherFolder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
355
+ e.projectId === state.projectId && path.resolve(e.folderPath) !== inFlightKey);
356
+ console.warn(color.yellow(otherFolder
357
+ ? ` ${targetDir} is on \`${myBranch}\`, but this project's temporary project is on ` +
358
+ `\`${heldBranch}\`, held by ${otherFolder.folderPath}. Pausing uploads from here so the ` +
359
+ `two branches do not mix. Run \`niro new-temp-project\` here to move it, or ` +
360
+ `\`niro discard-temp-project\` in that folder.`
361
+ : ` ${targetDir} is on \`${myBranch}\`, but its temporary project is still on ` +
362
+ `\`${heldBranch}\` — you switched branch in this folder. Pausing uploads so the two ` +
363
+ `branches do not mix. Run \`niro new-temp-project\` here to move the temporary project ` +
364
+ `onto \`${myBranch}\`.`));
365
+ }
366
+ return { notHolder: true };
367
+ }
368
+ if (notHolding.delete(inFlightKey)) {
369
+ console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
370
+ }
371
+ }
252
372
  const storedHashes = state.fileHashes || {};
253
373
  const currentHashes = {};
254
374
  const changed = [];
255
375
  const deleted = [];
256
376
 
257
377
  // Walk all currently included files
258
- const manifest = buildMinimalManifest(targetDir, state.includePatterns, state.excludePatterns);
378
+ const { files: manifest, failedDirs } = buildMinimalManifest(targetDir, state.includePatterns,
379
+ state.excludePatterns);
380
+
381
+ // The repo root itself could not be read — the folder was moved, unmounted, or its permissions
382
+ // changed. EVERY file looks missing, so a delete pass here would wipe the whole project. Abort the
383
+ // pass entirely; a later one recovers by itself once the folder is back.
384
+ if (failedDirs.includes("")) {
385
+ // Said once per folder, then silence until it reads again. Repeating this every 60s for a folder
386
+ // that has simply been moved is noise, and noise is how a real error gets missed.
387
+ if (!unreadableRoots.has(path.resolve(targetDir))) {
388
+ unreadableRoots.add(path.resolve(targetDir));
389
+ console.error(color.red(
390
+ ` Cannot read ${targetDir} — skipping this sync entirely rather than reporting every file ` +
391
+ `deleted. Check the folder still exists and is readable. ` +
392
+ `This will not be repeated until it can be read again.`));
393
+ }
394
+ return;
395
+ }
396
+ if (unreadableRoots.delete(path.resolve(targetDir))) {
397
+ console.log(color.green(` ${targetDir} is readable again — syncing resumed.`));
398
+ }
259
399
 
400
+ const unreadable = [];
260
401
  for (const relativePath of manifest) {
261
402
  const absPath = path.join(targetDir, relativePath);
262
403
  const hash = watchState.hashFile(absPath);
263
- if (!hash) continue;
404
+ if (!hash) {
405
+ // The walk that produced this manifest JUST FOUND this file, so it exists. Failing to READ it
406
+ // is not evidence that it was deleted — it is a permission error, a transient lock, a file being
407
+ // rewritten, or simply too many open files. hashFile returns null for every one of those.
408
+ //
409
+ // Skipping the file here used to leave it out of currentHashes, and the loop below reads
410
+ // "absent from currentHashes" as "deleted", so an unreadable file became a delete instruction to
411
+ // the server. Carrying the stored hash forward keeps it in the baseline and out of `deleted`.
412
+ unreadable.push(relativePath);
413
+ if (Object.prototype.hasOwnProperty.call(storedHashes, relativePath)) {
414
+ currentHashes[relativePath] = storedHashes[relativePath];
415
+ }
416
+ continue;
417
+ }
264
418
  currentHashes[relativePath] = hash;
265
419
  if (storedHashes[relativePath] !== hash) {
266
420
  changed.push(relativePath);
267
421
  }
268
422
  }
269
423
 
270
- // Files that were in stored hashes but no longer exist
424
+ if (unreadable.length > 0) {
425
+ console.warn(color.yellow(
426
+ ` ${unreadable.length} file(s) could not be read this pass — keeping them as-is rather than ` +
427
+ `reporting them deleted. Sample: ${unreadable.slice(0, 5).join(", ")}`));
428
+ }
429
+
430
+ // Files that were in stored hashes but are no longer present in the walk at all.
431
+ //
432
+ // "Not in the walk" only means "deleted" for parts of the tree the walk could actually READ. A
433
+ // directory that failed to list (permissions, a build holding it, too many open files, an NFS or
434
+ // Docker mount blip) yields no entries, which is indistinguishable from one whose files were all
435
+ // removed. Judging those would delete a subtree because we could not look at it.
436
+ const unreadableDirs = [];
271
437
  for (const rp of Object.keys(storedHashes)) {
272
- if (!currentHashes[rp]) {
273
- deleted.push(rp);
438
+ if (Object.prototype.hasOwnProperty.call(currentHashes, rp)) continue;
439
+ if (failedDirs.some(d => d && (rp === d || rp.startsWith(d + "/")))) {
440
+ unreadableDirs.push(rp);
441
+ currentHashes[rp] = storedHashes[rp]; // keep it tracked; we learned nothing about it
442
+ continue;
274
443
  }
444
+ deleted.push(rp);
445
+ }
446
+
447
+ if (unreadableDirs.length > 0) {
448
+ console.warn(color.yellow(
449
+ ` ${failedDirs.length} directory(ies) could not be read this pass, so ${unreadableDirs.length} ` +
450
+ `file(s) under them are being left alone rather than reported deleted. ` +
451
+ `Sample: ${failedDirs.slice(0, 3).join(", ")}`));
275
452
  }
276
453
 
277
454
  if (changed.length === 0 && deleted.length === 0) {
278
455
  if (!quiet) console.log(` No changes since last sync.`);
279
- watchState.update(targetDir, { fileHashes: currentHashes });
280
- return;
456
+ watchState.mergeFileHashes(targetDir, { set: currentHashes, expectedGitId: gitId });
457
+ // Nothing to send, so the server was never contacted and this pass is not evidence it is healthy.
458
+ // This is the MAJORITY of backstop ticks: treated as a success it cleared the failure count on the
459
+ // very next tick after one, so the backoff could never grow past its first step.
460
+ return { noContact: true };
281
461
  }
282
462
 
283
463
  // A quiet backstop run only speaks up when it actually found something fs.watch missed.
@@ -287,6 +467,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
287
467
  // using the same helper as init/add-repo. A gitignored secret edited after
288
468
  // onboarding (e.g. a raw .env) must never be re-uploaded here.
289
469
  let changedToUpload = changed;
470
+ const gitignoreDropped = [];
290
471
  if (changed.length > 0) {
291
472
  const drops = folderSync.gitignoreDrops(targetDir, changed);
292
473
  if (drops.size > 0) {
@@ -294,7 +475,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
294
475
  // Don't persist hashes for dropped (gitignored / unverifiable) files — they
295
476
  // were never uploaded, so leave them out of state so they're re-evaluated
296
477
  // rather than recorded as synced.
297
- for (const rp of drops) delete currentHashes[rp];
478
+ for (const rp of drops) { delete currentHashes[rp]; gitignoreDropped.push(rp); }
298
479
  }
299
480
  }
300
481
 
@@ -313,21 +494,189 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
313
494
  await agentApi.completeSync(gitId, sessionId);
314
495
  }
315
496
 
497
+ // Re-check each file immediately before telling the server to remove it. The walk that built this
498
+ // list ran BEFORE the uploads above, which on a large delta take minutes, so by now a file may exist
499
+ // again — or may never have been gone, having merely been unreadable at the wrong moment. The live
500
+ // per-file path already stats before deleting; this one fired blind.
501
+ // Per-file failures are swallowed below so one bad path cannot abort the pass. They still have to be
502
+ // REPORTED, or a pass in which everything failed is indistinguishable from one in which all was well.
503
+ let failures = 0;
504
+ const reappeared = [];
505
+ // Only paths the server actually accepted. Marking a FAILED delete as done drops it from the
506
+ // baseline, and once dropped it can never re-enter `deleted` — so the file stays on the server, stays
507
+ // indexed, and nothing retries it. With the burst guard that is systematic: delete 500 files, the
508
+ // first 100 land, the rest are refused, and all 500 were being forgotten.
509
+ const deletedOk = [];
316
510
  for (const rp of deleted) {
511
+ // Existing on disk is NOT on its own a reason to keep it. A file leaves the manifest for two
512
+ // different reasons: it was removed from disk, or it stopped matching the include/exclude
513
+ // patterns. Only the first can "come back". Rescuing on existence alone meant adding an exclude
514
+ // pattern no longer removed the file from Niro — so a path excluded BECAUSE it holds secrets
515
+ // would have gone on being indexed for ever.
516
+ if (fs.existsSync(path.join(targetDir, rp)) && inManifestScope(rp, state)) {
517
+ reappeared.push(rp);
518
+ continue;
519
+ }
317
520
  try {
318
521
  await agentApi.deleteFile(gitId, rp);
522
+ deletedOk.push(rp);
319
523
  } catch (err) {
524
+ // Swallowed so one bad path cannot abort the pass, but COUNTED: a pass whose deltas are deletes
525
+ // only never throws, so without this the caller saw a clean resolve while every single delete was
526
+ // being refused, cleared the backoff, and retried the whole set 60 seconds later. Forever.
527
+ failures++;
320
528
  console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
321
529
  }
322
530
  }
323
-
324
- watchState.update(targetDir, { fileHashes: currentHashes, lastSyncedAt: new Date().toISOString() });
531
+ if (reappeared.length > 0) {
532
+ console.warn(color.yellow(
533
+ ` ${reappeared.length} file(s) were back on disk by the time the delete ran — not deleted. ` +
534
+ `Sample: ${reappeared.slice(0, 5).join(", ")}`));
535
+ // Deliberately NOT re-hashed into the baseline. The file may have come back with DIFFERENT bytes
536
+ // (a branch switch or stash pop mid-upload), and it was in `deleted` rather than `changed`, so it
537
+ // was never uploaded — recording the new hash would claim the server holds content it never
538
+ // received. Leaving its OLD entry untouched is what makes the next pass see a hash mismatch and
539
+ // upload it. It is in scope and on disk, so that pass finds it in the manifest.
540
+ }
541
+ const actuallyDeleted = deletedOk;
542
+
543
+ // Merge rather than replace. Removals are explicit: the files we actually deleted, plus any the
544
+ // gitignore policy dropped (those were never uploaded, so they must not stay recorded as synced).
545
+ // expectedGitId: this whole pass computed hashes for `gitId`. If a takeover or discard re-pointed the
546
+ // folder while it ran, these describe a repo it is no longer registered against and must be dropped.
547
+ if (!watchState.mergeFileHashes(targetDir, {
548
+ set: currentHashes,
549
+ remove: [...actuallyDeleted, ...gitignoreDropped],
550
+ patch: { lastSyncedAt: new Date().toISOString() },
551
+ expectedGitId: gitId,
552
+ })) {
553
+ console.log(color.dim(" Folder was re-pointed during this sync — its results were not recorded."));
554
+ }
325
555
  console.log(` ${color.green("✓")} Delta sync complete.`);
556
+ return { failures };
326
557
  } finally {
327
558
  syncInFlight.delete(inFlightKey);
328
559
  }
329
560
  }
330
561
 
562
+ /** Queue one vanished path and (re)start this folder's settle timer. */
563
+ function queueDelete(targetDir, relativePath, settleMs = DELETE_SETTLE_MS) {
564
+ const key = path.resolve(targetDir);
565
+ let entry = pendingDeletes.get(key);
566
+ if (!entry) {
567
+ entry = { paths: new Set(), timer: null };
568
+ pendingDeletes.set(key, entry);
569
+ }
570
+ entry.paths.add(relativePath);
571
+ // Reset, never extend-if-absent: while files keep vanishing the operation is still running, and
572
+ // nothing in the queue should be believed until it stops.
573
+ if (entry.timer) clearTimeout(entry.timer);
574
+ entry.timer = setTimeout(() => {
575
+ flushPendingDeletes(targetDir).catch(err =>
576
+ console.error(color.red(` Delete flush failed for ${targetDir}: ${err.message}`)));
577
+ }, settleMs);
578
+ if (entry.timer.unref) entry.timer.unref(); // must never hold the daemon open
579
+ }
580
+
581
+ /**
582
+ * The folder has gone quiet. Look again at everything queued: send the deletes that are still real,
583
+ * and forget the ones that came back.
584
+ *
585
+ * A file that returned is left ALONE rather than re-uploaded here. Its baseline entry was never
586
+ * removed (we never sent the delete), so if the bytes changed, git's own write fires a change event
587
+ * that takes the normal upload path, and the 60-second backstop re-scan covers a coalesced event.
588
+ * Duplicating the upload path here would mean duplicating its gitignore and secret checks too.
589
+ */
590
+ async function flushPendingDeletes(targetDir) {
591
+ const key = path.resolve(targetDir);
592
+ const entry = pendingDeletes.get(key);
593
+ if (!entry) return;
594
+ pendingDeletes.delete(key);
595
+ if (entry.timer) clearTimeout(entry.timer);
596
+
597
+ // Re-read state at flush time: a `niro takeover` or `niro release` during the settle window changes
598
+ // (or removes) the gitId these deletes were queued against.
599
+ const fresh = watchState.get(targetDir, api.currentBaseUrl());
600
+ if (!fresh || !fresh.gitId) return;
601
+ if (notHolding.has(key)) return;
602
+
603
+ const stillGone = [];
604
+ const returned = [];
605
+ for (const rp of entry.paths) {
606
+ if (fs.existsSync(path.join(targetDir, rp))) returned.push(rp);
607
+ else stillGone.push(rp);
608
+ }
609
+
610
+ if (returned.length > 0) {
611
+ console.log(color.dim(
612
+ ` ${returned.length} file(s) reappeared while settling — not deleted` +
613
+ (returned.length <= 3 ? `: ${returned.join(", ")}` : "")));
614
+ }
615
+
616
+ for (const rp of stillGone) {
617
+ try {
618
+ await agentApi.deleteFile(fresh.gitId, rp);
619
+ watchState.mergeFileHashes(targetDir, {
620
+ remove: [rp],
621
+ patch: { lastSyncedAt: new Date().toISOString() },
622
+ expectedGitId: fresh.gitId,
623
+ });
624
+ console.log(` ${color.yellow("−")} ${rp}`);
625
+ } catch (err) {
626
+ // Keep the baseline entry so a later pass retries. Dropping it here is how a refused delete
627
+ // became permanent (see fb3ff0a).
628
+ console.error(color.red(` Failed to delete ${rp}: ${err.message}`));
629
+ }
630
+ }
631
+ }
632
+
633
+
634
+ /**
635
+ * One backstop pass for a folder. Extracted from the interval so the backoff gate at the top can
636
+ * actually be exercised — adversarial review found the gate could be deleted with the whole suite
637
+ * still green, because the only thing that called it was a timer no test drives.
638
+ */
639
+ function backstopTick(targetDir) {
640
+ const fresh = watchState.get(targetDir, api.currentBaseUrl());
641
+ if (!fresh || !fresh.gitId) return; // folder released — nothing to sync
642
+ // Back off after failures instead of retrying every 60s forever. Retrying at full rate against a
643
+ // server that is failing does not fix anything and buries the real error in its own noise.
644
+ const backoffKey = path.resolve(targetDir);
645
+ if (!shouldAttemptBackstop(backoffKey)) return;
646
+ deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true })
647
+ .then(result => {
648
+ // A run that never reached the server proves nothing about the server. The in-flight guard
649
+ // returns at once when another sync for this folder is already going, and during an outage a
650
+ // large delta holds that flag for minutes — so a skipped tick was clearing the failure count
651
+ // and printing a green "Sync recovered" in the middle of the incident, while the real run was
652
+ // still heading for its own failure. The count then never grew past one, so the backoff never
653
+ // climbed towards its ceiling and kept hammering every two minutes: both things this backoff
654
+ // was added to stop.
655
+ // Only a pass that actually reached the server and had nothing fail counts as a recovery.
656
+ // A skipped run (another sync in flight) and a no-change run both contact nothing, and a pass
657
+ // whose per-file deletes were all refused resolves cleanly — so all three used to clear the
658
+ // failure count and print a green "Sync recovered" during an outage, which is how the retry rate
659
+ // never decayed and the real error stayed buried.
660
+ if (!result || result.skipped || result.noContact || result.notHolder) return;
661
+ if (result.failures > 0) {
662
+ const delayMs = recordBackstopFailure(backoffKey, Date.now());
663
+ console.error(color.red(
664
+ ` ${result.failures} file operation(s) failed for ${targetDir} — retrying in ` +
665
+ `${Math.round(delayMs / 60_000)} min.`));
666
+ return;
667
+ }
668
+ if (recordBackstopSuccess(backoffKey)) {
669
+ console.log(color.green(` Sync recovered for ${targetDir}.`));
670
+ }
671
+ })
672
+ .catch(err => {
673
+ const delayMs = recordBackstopFailure(backoffKey, Date.now());
674
+ console.error(color.red(
675
+ ` Backstop re-scan failed for ${targetDir}: ${err.message} — retrying in ` +
676
+ `${Math.round(delayMs / 60_000)} min.`));
677
+ });
678
+ }
679
+
331
680
  async function handleFileEvent(gitId, targetDir, relativePath, state) {
332
681
  // Re-read this folder's current state on every event instead of trusting the value
333
682
  // captured when the daemon started. A mid-session `niro takeover` re-points the folder
@@ -335,6 +684,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
335
684
  // daemon kept uploading to the OLD gitId — every edit failed with "Repository not found"
336
685
  // until the daemon was manually restarted. Reading fresh also picks up include/exclude
337
686
  // pattern changes from `niro edit`.
687
+ //
688
+ // Held back by the last sync pass: this folder is not the one its temporary project reflects, so
689
+ // uploading would mix two branches into one copy. Re-checked every pass, so it clears by itself.
690
+ if (notHolding.has(path.resolve(targetDir))) return;
338
691
  const fresh = watchState.get(targetDir, api.currentBaseUrl());
339
692
  if (!fresh || !fresh.gitId) {
340
693
  // Folder was released / is no longer tracked for this backend — stop syncing it.
@@ -386,25 +739,29 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
386
739
 
387
740
  const absPath = path.join(targetDir, relativePath);
388
741
 
742
+ // Symmetry: if we would never UPLOAD this path, we must never ask the server to DELETE it. The
743
+ // accept-set check used to sit below the delete branch, so an editor's scratch file — never sent
744
+ // to us in the first place — still produced a delete call for a path the server had never heard
745
+ // of. That is 21% of all file events in the customer's log (3,226 of 15,223), every one of them a
746
+ // wasted round trip, a wasted journal record, and a count against the server's delete-burst budget
747
+ // that could trip the guard on churn alone.
748
+ //
749
+ // Safe because uploadability is decided by the filename, which does not change for a given path:
750
+ // if it is not uploadable now it was never uploaded. A file the accept set USED to include is
751
+ // still reachable by the backstop re-scan, which works from the stored baseline rather than from
752
+ // live events.
753
+ if (!folderSync.isUploadableName(relativePath)) return;
754
+
389
755
  if (!fs.existsSync(absPath)) {
390
- // File deleted
391
- try {
392
- await agentApi.deleteFile(gitId, relativePath);
393
- const hashes = { ...(watchState.get(targetDir) || {}).fileHashes };
394
- delete hashes[relativePath];
395
- watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
396
- console.log(` ${color.yellow("−")} ${relativePath}`);
397
- } catch (err) {
398
- console.error(color.red(` Failed to delete ${relativePath}: ${err.message}`));
399
- }
756
+ // Gone RIGHT NOW is not the same as deleted. Queue it and look again once the folder is quiet.
757
+ queueDelete(targetDir, relativePath);
400
758
  return;
401
759
  }
402
760
 
403
- // Only upload files in the accept set (source / structural / committed config).
404
- // matchesPatterns can pass anything when the server-supplied includePatterns are
405
- // empty; this keeps the per-file event from uploading stray files (e.g. a *.txt
406
- // that could carry secrets), matching what the manifest walk would offer.
407
- if (!folderSync.isUploadableName(relativePath)) return;
761
+ // It exists, so any queued delete for it was a transient gap (a rewrite, not a removal). Drop it
762
+ // immediately rather than waiting for the settle timer to work it out.
763
+ const pending = pendingDeletes.get(path.resolve(targetDir));
764
+ if (pending) pending.paths.delete(relativePath);
408
765
 
409
766
  // File changed or created
410
767
  const newHash = watchState.hashFile(absPath);
@@ -422,8 +779,11 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
422
779
 
423
780
  try {
424
781
  await agentApi.upsertFile(gitId, relativePath, absPath);
425
- const hashes = { ...storedHashes, [relativePath]: newHash };
426
- watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
782
+ watchState.mergeFileHashes(targetDir, {
783
+ set: { [relativePath]: newHash },
784
+ patch: { lastSyncedAt: new Date().toISOString() },
785
+ expectedGitId: gitId,
786
+ });
427
787
  console.log(` ${color.green("+")} ${relativePath}`);
428
788
  } catch (err) {
429
789
  console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
@@ -435,6 +795,13 @@ function shouldIgnore(relativePath) {
435
795
  return parts.some(p => IGNORED_DIRS.has(p) || p.startsWith("."));
436
796
  }
437
797
 
798
+ /** Mirrors buildMinimalManifest's membership rule: empty includePatterns means everything is in. */
799
+ function inManifestScope(relativePath, state) {
800
+ const include = state.includePatterns;
801
+ if (!include || include.length === 0) return true;
802
+ return matchesPatterns(relativePath, include, state.excludePatterns);
803
+ }
804
+
438
805
  function matchesPatterns(relativePath, includePatterns, excludePatterns) {
439
806
  if (!includePatterns || includePatterns.length === 0) return true;
440
807
  const matched = includePatterns.some(p => minimatch(relativePath, p));
@@ -454,12 +821,27 @@ function minimatch(filePath, pattern) {
454
821
  }
455
822
 
456
823
  /** Walk the directory respecting stored patterns to find all currently included files. */
824
+ /**
825
+ * @returns {{files: string[], failedDirs: string[]}} — `failedDirs` holds the repo-relative directories
826
+ * the walk could NOT read ("" means the repo root itself). A caller must treat those as UNKNOWN, never
827
+ * as empty: a directory that cannot be listed looks exactly like a directory whose files were all
828
+ * deleted, and acting on that difference is how a whole project gets wiped.
829
+ */
457
830
  function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
458
831
  const results = [];
832
+ const failedDirs = [];
459
833
 
460
834
  function walk(dir) {
461
835
  let entries;
462
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
836
+ try {
837
+ entries = fs.readdirSync(dir, { withFileTypes: true });
838
+ } catch {
839
+ // Swallowing this was the defect. The walk returned a SHORT list with no signal, the caller read
840
+ // "absent from the walk" as "deleted", and every file under here was reported gone. Renaming a
841
+ // watched folder emptied the whole manifest and deleted the entire project server-side.
842
+ failedDirs.push(path.relative(rootDir, dir).replace(/\\/g, "/"));
843
+ return;
844
+ }
463
845
  for (const entry of entries) {
464
846
  const fullPath = path.join(dir, entry.name);
465
847
  const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
@@ -490,9 +872,12 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
490
872
  }
491
873
  }
492
874
  walk(rootDir);
493
- return results;
875
+ return { files: results, failedDirs };
494
876
  }
495
877
 
496
878
  // handleFileEvent + deltaSyncOnResume are exported for unit testing the
497
879
  // gitignore/fail-closed guards without a live server or fs.watch loop.
498
- module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure };
880
+ module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
881
+ queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
882
+ unreadableRoots,
883
+ nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };