{"repo":"NousResearch/hermes-agent","public_id":"39945","display_label":"File sync rate-limit clock advances on error","summary":"These issues and PRs address a bug where an unsuccessful file sync cycle incorrectly advances the rate-limit clock, suppressing the expected immediate retry. Multiple fix attempts and related salvage PRs are present.","subsystem":"environments","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":67927},"resolution":{"state":"resolved","by":"pr-67927"},"freshness":{"state":"verified","last_verified_at":"2026-07-20T09:25:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":39945,"pr_number":67927,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":39945,"title":"FileSyncManager: a failed sync() advances the rate-limit clock, suppressing the documented retry","url":null,"state":"closed"},{"kind":"pr","number":39946,"title":"fix(file-sync): don't rate-limit retry after a failed sync cycle","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67927","number":67927},{"kind":"pr","value":"#39945","number":39945}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39952,"title":"fix(file-sync): retry immediately after failed sync","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39945","number":39945}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":39958,"title":"fix(file-sync): don't advance rate-limit clock on failed sync()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67927","number":67927},{"kind":"pr","value":"#39946","number":39946}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40040,"title":"fix(file-sync): retry immediately after failed sync","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67927","number":67927},{"kind":"pr","value":"#39946","number":39946},{"kind":"pr","value":"#39945","number":39945}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40048,"title":"fix(file_sync): don't advance rate-limit clock on failed sync cycle","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39946","number":39946},{"kind":"pr","value":"#39945","number":39945},{"kind":"pr","value":"#39958","number":39958},{"kind":"pr","value":"#40040","number":40040}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":67927,"title":"fix(file-sync): don't rate-limit retry after a failed sync cycle (salvage #39946)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_file_sync.py","tools/environments/file_sync.py"],"fix_hunks":[{"file":"tests/tools/test_file_sync.py","hunks":["@@ -225,6 +225,42 @@ def test_env_var_forces_sync(self, tmp_files, tmp_path):\n             mgr.sync()\n         assert upload.call_count == 1\n \n+    def test_failed_sync_does_not_suppress_next_retry(self, tmp_files, monkeypatch):\n+        \"\"\"A failed sync must not advance the rate-limit clock.\n+\n+        Regression: the failure path used to set ``_last_sync_time`` on\n+        rollback, so the next non-forced ``sync()`` within ``sync_interval``\n+        hit the rate-limit guard and returned early — silently suppressing the\n+        retry the rollback had just prepared and leaving the remote stale.\n+        \"\"\"\n+        from tools.environments import file_sync\n+\n+        clock = {\"t\": 1000.0}\n+        monkeypatch.setattr(file_sync, \"_monotonic\", lambda: clock[\"t\"])\n+\n+        upload = MagicMock(side_effect=RuntimeError(\"transport down\"))\n+        mgr = FileSyncManager(\n+            get_files_fn=_make_get_files(tmp_files),\n+            upload_fn=upload,\n+            delete_fn=MagicMock(),\n+            sync_interval=10.0,\n+        )\n+\n+        # First sync fails (forced bypasses the guard); state rolls back.\n+        mgr.sync(force=True)\n+        assert upload.call_count >= 1\n+\n+        # Transport recovers; advance the clock by LESS than the interval.\n+        upload.reset_mock()\n+        upload.side_effect = None\n+        clock[\"t\"] = 1002.0  # 2s later, < 10s interval\n+\n+        # The next non-forced cycle must retry, not be rate-limited away.\n+        mgr.sync()\n+        assert upload.call_count == 3, (\n+            \"a failed sync must not rate-limit the next retry\"\n+        )\n+\n \n class TestEdgeCases:\n     def test_empty_file_list(self):"]},{"file":"tools/environments/file_sync.py","hunks":["@@ -35,6 +35,9 @@\n # ``time.sleep`` globally because ``time`` is the module object; under xdist\n # that lets unrelated background threads inflate retry-test call counts.\n _sleep = time.sleep\n+# Same rationale for the rate-limit clock: tests patch ``_monotonic``\n+# instead of ``time.monotonic`` on the shared module object.\n+_monotonic = time.monotonic\n \n _SYNC_INTERVAL_SECONDS = 5.0\n _FORCE_SYNC_ENV = \"HERMES_FORCE_FILE_SYNC\"","@@ -169,7 +172,7 @@ def sync(self, *, force: bool = False) -> None:\n         On failure, state rolls back so the next cycle retries everything.\n         \"\"\"\n         if not force and not os.environ.get(_FORCE_SYNC_ENV):\n-            now = time.monotonic()\n+            now = _monotonic()\n             if now - self._last_sync_time < self._sync_interval:\n                 return\n ","@@ -193,7 +196,7 @@ def sync(self, *, force: bool = False) -> None:\n         to_delete = [p for p in self._synced_files if p not in current_remote_paths]\n \n         if not to_upload and not to_delete:\n-            self._last_sync_time = time.monotonic()\n+            self._last_sync_time = _monotonic()\n             return\n \n         # Snapshot for rollback (only when there's work to do)","@@ -227,12 +230,17 @@ def sync(self, *, force: bool = False) -> None:\n                 self._pushed_hashes.pop(p, None)\n \n             self._synced_files = new_files\n-            self._last_sync_time = time.monotonic()\n+            self._last_sync_time = _monotonic()\n \n         except Exception as exc:\n             self._synced_files = prev_files\n             self._pushed_hashes = prev_hashes\n-            self._last_sync_time = time.monotonic()\n+            # Do NOT advance _last_sync_time here: a failed cycle rolls state\n+            # back so the next cycle can retry. Bumping the rate-limit clock on\n+            # failure would make the next non-forced sync() return early (the\n+            # guard above), suppressing that retry for up to _sync_interval and\n+            # leaving the remote with stale files — contradicting this method's\n+            # documented \"next cycle retries everything\" contract.\n             logger.warning(\"file_sync: sync failed, rolled back state: %s\", exc)\n \n     # ------------------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}