Anurag commited on
Commit
d02beec
Β·
1 Parent(s): e1afa1b

Add NVIDIA StepFun flash model

Browse files
Files changed (6) hide show
  1. .env.example +18 -1
  2. README.md +4 -0
  3. env-builder.js +4 -2
  4. jupyter-devdata-sync.py +46 -5
  5. openclaw-sync.py +177 -29
  6. start.sh +55 -30
.env.example CHANGED
@@ -105,6 +105,7 @@ LLM_API_KEY=your_api_key_here
105
  #
106
  # NVIDIA (NVIDIA_API_KEY):
107
  # - nvidia/nemotron-3-super-120b-a12b
 
108
  #
109
  # Groq (GROQ_API_KEY):
110
  # - groq/mixtral-8x7b-32768
@@ -273,7 +274,7 @@ LLM_API_KEY_FALLBACK_ENABLED=true
273
  # Optional: explicitly pin model lists per provider for Control UI visibility
274
  # when provider keys are configured.
275
  # Format: comma-separated model IDs
276
- # NVIDIA_MODELS=meta/llama-3.1-70b-instruct,nvidia/llama-3.1-nemotron-70b-instruct
277
  # OPENAI_MODELS=gpt-4o-mini,gpt-4.1
278
  # GROQ_MODELS=llama-3.3-70b-versatile,deepseek-r1-distill-llama-70b
279
  # independently and in parallel.
@@ -395,6 +396,22 @@ OPENCLAW_CONFIG_SETTLE_SECONDS=3
395
  # Minimum gap between sessions-triggered immediate syncs (seconds). Default: 30.
396
  SESSIONS_MIN_SYNC_GAP=30
397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  # Webhooks: Standard POST notifications for lifecycle events
399
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
400
 
 
105
  #
106
  # NVIDIA (NVIDIA_API_KEY):
107
  # - nvidia/nemotron-3-super-120b-a12b
108
+ # - nvidia/stepfun-ai/step-3.7-flash
109
  #
110
  # Groq (GROQ_API_KEY):
111
  # - groq/mixtral-8x7b-32768
 
274
  # Optional: explicitly pin model lists per provider for Control UI visibility
275
  # when provider keys are configured.
276
  # Format: comma-separated model IDs
277
+ # NVIDIA_MODELS=meta/llama-3.1-70b-instruct,nvidia/llama-3.1-nemotron-70b-instruct,nvidia/stepfun-ai/step-3.7-flash
278
  # OPENAI_MODELS=gpt-4o-mini,gpt-4.1
279
  # GROQ_MODELS=llama-3.3-70b-versatile,deepseek-r1-distill-llama-70b
280
  # independently and in parallel.
 
396
  # Minimum gap between sessions-triggered immediate syncs (seconds). Default: 30.
397
  SESSIONS_MIN_SYNC_GAP=30
398
 
399
+ # Max seconds a one-off sync waits for another sync process to release its lock.
400
+ # Startup background sync still keeps retrying; this mainly prevents shutdown or
401
+ # gateway-restart saves from hanging forever behind a stuck upload. Default: 20.
402
+ SYNC_LOCK_TIMEOUT=20
403
+
404
+ # Shutdown/restart one-shot sync upload budgets. These are intentionally much
405
+ # larger than SYNC_LOCK_TIMEOUT so 40-50 MB files are not killed mid-upload just
406
+ # because another sync lock check needed to be bounded. Set to 0 to disable the
407
+ # outer command timeout completely. Defaults: 120 seconds each.
408
+ SYNC_SETTLED_TIMEOUT=120
409
+ SYNC_FINAL_TIMEOUT=120
410
+
411
+ # Lock wait used only by shutdown/restart one-shot syncs. Keep this short
412
+ # because start.sh stops the background sync loop before these commands run.
413
+ SYNC_ONE_SHOT_LOCK_TIMEOUT=5
414
+
415
  # Webhooks: Standard POST notifications for lifecycle events
416
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
417
 
README.md CHANGED
@@ -188,6 +188,10 @@ HuggingClaw automatically syncs your workspace (chats, settings, sessions) to a
188
  | `OPENCLAW_CONFIG_WATCH_INTERVAL` | `1` | How often to check `openclaw.json` for immediate settings sync |
189
  | `OPENCLAW_CONFIG_SETTLE_SECONDS` | `3` | How long `openclaw.json` must stay valid and unchanged before syncing |
190
  | `SESSIONS_MIN_SYNC_GAP` | `30` | Minimum seconds between session-triggered immediate syncs |
 
 
 
 
191
 
192
  ## πŸ“¦ Ephemeral Package Re-install *(Optional)*
193
 
 
188
  | `OPENCLAW_CONFIG_WATCH_INTERVAL` | `1` | How often to check `openclaw.json` for immediate settings sync |
189
  | `OPENCLAW_CONFIG_SETTLE_SECONDS` | `3` | How long `openclaw.json` must stay valid and unchanged before syncing |
190
  | `SESSIONS_MIN_SYNC_GAP` | `30` | Minimum seconds between session-triggered immediate syncs |
191
+ | `SYNC_LOCK_TIMEOUT` | `20` | Max seconds one-off syncs wait for another sync lock before failing clearly |
192
+ | `SYNC_SETTLED_TIMEOUT` | `120` | Shutdown/restart settled-sync upload budget; set `0` to disable the outer timeout |
193
+ | `SYNC_FINAL_TIMEOUT` | `120` | Shutdown/restart final catch-up sync upload budget; set `0` to disable the outer timeout |
194
+ | `SYNC_ONE_SHOT_LOCK_TIMEOUT` | `5` | Short lock wait for shutdown/restart one-shot syncs after the background loop is stopped |
195
 
196
  ## πŸ“¦ Ephemeral Package Re-install *(Optional)*
197
 
env-builder.js CHANGED
@@ -142,7 +142,8 @@ const MODEL_CATALOGS = {
142
  "NVIDIA": [
143
  "nvidia/nemotron-3-super-120b-a12b",
144
  "nvidia/nemotron-4-340b-instruct",
145
- "nvidia/llama-3.1-nemotron-70b-instruct"
 
146
  ],
147
  "KiloCode": [
148
  "kilocode/anthropic/claude-opus-4.7",
@@ -376,7 +377,8 @@ const MODEL_CATALOGS = {
376
  "NVIDIA_MODELS": [
377
  "nvidia/nemotron-3-super-120b-a12b",
378
  "nvidia/nemotron-4-340b-instruct",
379
- "nvidia/llama-3.1-nemotron-70b-instruct"
 
380
  ],
381
  "KILOCODE_MODELS": [
382
  "kilocode/anthropic/claude-opus-4.7",
 
142
  "NVIDIA": [
143
  "nvidia/nemotron-3-super-120b-a12b",
144
  "nvidia/nemotron-4-340b-instruct",
145
+ "nvidia/llama-3.1-nemotron-70b-instruct",
146
+ "nvidia/stepfun-ai/step-3.7-flash"
147
  ],
148
  "KiloCode": [
149
  "kilocode/anthropic/claude-opus-4.7",
 
377
  "NVIDIA_MODELS": [
378
  "nvidia/nemotron-3-super-120b-a12b",
379
  "nvidia/nemotron-4-340b-instruct",
380
+ "nvidia/llama-3.1-nemotron-70b-instruct",
381
+ "nvidia/stepfun-ai/step-3.7-flash"
382
  ],
383
  "KILOCODE_MODELS": [
384
  "kilocode/anthropic/claude-opus-4.7",
jupyter-devdata-sync.py CHANGED
@@ -167,6 +167,10 @@ def _matches_prefix(parts: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
167
  return len(parts) >= len(prefix) and parts[:len(prefix)] == prefix
168
 
169
 
 
 
 
 
170
  def should_skip(p: Path):
171
  # Reserved sync helper files are not user data. Old datasets may still have
172
  # these markers, but fresh snapshots no longer create them.
@@ -188,15 +192,52 @@ def should_skip(p: Path):
188
  # Skip any component whose name looks like a secret file/dir.
189
  return any(_name_is_secret(part) for part in parts)
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  def snapshot(src: Path, dst: Path) -> tuple[bool, set[str]]:
192
  had_copy_failures = False
193
  protected_large_files: set[str] = set()
194
- for p in src.rglob("*"):
195
  rel = p.relative_to(src)
196
- if should_skip(rel):
197
- continue
198
- if p.is_symlink():
199
- continue
200
  target = dst / rel
201
  if p.is_dir():
202
  # Keep parent directories for files copied later in this snapshot.
 
167
  return len(parts) >= len(prefix) and parts[:len(prefix)] == prefix
168
 
169
 
170
+ def _is_prefix_of(parts: tuple[str, ...], full_path: tuple[str, ...]) -> bool:
171
+ return len(parts) <= len(full_path) and full_path[:len(parts)] == parts
172
+
173
+
174
  def should_skip(p: Path):
175
  # Reserved sync helper files are not user data. Old datasets may still have
176
  # these markers, but fresh snapshots no longer create them.
 
192
  # Skip any component whose name looks like a secret file/dir.
193
  return any(_name_is_secret(part) for part in parts)
194
 
195
+
196
+ def iter_sync_tree(root: Path):
197
+ """Yield syncable DevData paths without descending into excluded trees."""
198
+ if not root.exists():
199
+ return
200
+
201
+ for dirpath, dirnames, filenames in os.walk(root):
202
+ dir_path = Path(dirpath)
203
+ try:
204
+ dir_rel = dir_path.relative_to(root)
205
+ except ValueError:
206
+ dir_rel = Path()
207
+
208
+ kept_dirnames: list[str] = []
209
+ for dirname in sorted(dirnames):
210
+ rel = dir_rel / dirname
211
+ child = dir_path / dirname
212
+ rel_parts = rel.parts
213
+ # Do not prune ancestors of explicitly allowed Jupyter settings
214
+ # paths. should_skip(.local/share/jupyter) is true by design for
215
+ # files under that tree, but we must still descend through the
216
+ # parent dirs to reach lab/user-settings and lab/workspaces.
217
+ allowed_ancestor = any(
218
+ _is_prefix_of(rel_parts, prefix) for prefix in JUPYTER_DATA_ALLOW_PREFIXES
219
+ )
220
+ if child.is_symlink() or (should_skip(rel) and not allowed_ancestor):
221
+ continue
222
+ kept_dirnames.append(dirname)
223
+ dirnames[:] = kept_dirnames
224
+
225
+ for dirname in kept_dirnames:
226
+ yield dir_path / dirname
227
+
228
+ for filename in sorted(filenames):
229
+ rel = dir_rel / filename
230
+ child = dir_path / filename
231
+ if child.is_symlink() or should_skip(rel):
232
+ continue
233
+ yield child
234
+
235
+
236
  def snapshot(src: Path, dst: Path) -> tuple[bool, set[str]]:
237
  had_copy_failures = False
238
  protected_large_files: set[str] = set()
239
+ for p in iter_sync_tree(src):
240
  rel = p.relative_to(src)
 
 
 
 
241
  target = dst / rel
242
  if p.is_dir():
243
  # Keep parent directories for files copied later in this snapshot.
openclaw-sync.py CHANGED
@@ -51,6 +51,7 @@ CONFIG_SETTLE_SECONDS = max(
51
  float(os.environ.get("OPENCLAW_CONFIG_SETTLE_SECONDS", "3")),
52
  )
53
  SESSIONS_MIN_SYNC_GAP = int(os.environ.get("SESSIONS_MIN_SYNC_GAP", "30"))
 
54
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
55
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
56
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
@@ -88,6 +89,22 @@ EXCLUDED_STATE_NAMES = {
88
  # "WhatsApp enabled but not installing on restart".
89
  "extensions",
90
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  SESSIONS_ROOT = OPENCLAW_HOME / "agents"
92
  WHATSAPP_CREDS_DIR = OPENCLAW_HOME / "credentials" / "whatsapp" / "default"
93
  WHATSAPP_BACKUP_DIR = STATE_DIR / "credentials" / "whatsapp" / "default"
@@ -100,6 +117,20 @@ WorkspaceMarker: TypeAlias = tuple[int, int, int, str]
100
  # Set True when prune fails so the next sync pass bypasses the fingerprint
101
  # early-exit and retries the prune even if no local files changed.
102
  _prune_needed: bool = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
 
105
  def write_status(status: str, message: str) -> None:
@@ -133,6 +164,86 @@ def _remove_path(path: Path) -> None:
133
  path.unlink(missing_ok=True)
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def _copy_hot_directory_snapshot(source_path: Path, tmp_path: Path) -> bool:
137
  """Best-effort mirror of a hot directory into *tmp_path*.
138
 
@@ -259,6 +370,7 @@ def copy_state_entry_with_retry(source_path: Path, backup_path: Path, attempts:
259
  def snapshot_state_into_workspace() -> bool:
260
  had_copy_failures = False
261
  try:
 
262
  STATE_DIR.mkdir(parents=True, exist_ok=True)
263
  # Atomic snapshot: copy to a staging dir first, then rename.
264
  # This prevents a half-written (or empty) backup if we crash mid-copy,
@@ -274,7 +386,7 @@ def snapshot_state_into_workspace() -> bool:
274
  skipped_entries: list[tuple[str, Exception]] = []
275
  copied_entry_names: set[str] = set()
276
  for source_path in OPENCLAW_HOME.iterdir():
277
- if source_path.name in EXCLUDED_STATE_NAMES:
278
  continue
279
 
280
  backup_path = staging_dir / source_path.name
@@ -300,7 +412,9 @@ def snapshot_state_into_workspace() -> bool:
300
  # dataset on the next sync. Only remove an entry from staging when the
301
  # source has genuinely been deleted from OPENCLAW_HOME.
302
  for staged_path in list(staging_dir.iterdir()):
303
- if staged_path.name in EXCLUDED_STATE_NAMES:
 
 
304
  continue
305
  if staged_path.name in copied_entry_names:
306
  continue
@@ -425,13 +539,8 @@ def locally_existing_large_files(root: Path) -> set[str]:
425
  protected: set[str] = set()
426
  if not root.exists():
427
  return protected
428
- for path in root.rglob("*"):
429
- if not path.is_file():
430
- continue
431
  rel = path.relative_to(root).as_posix()
432
- parts = Path(rel).parts
433
- if any(part in EXCLUDED_SYNC_DIRS for part in parts):
434
- continue
435
  try:
436
  if path.stat().st_size > MAX_FILE_SIZE_BYTES:
437
  protected.add(rel)
@@ -441,6 +550,7 @@ def locally_existing_large_files(root: Path) -> set[str]:
441
 
442
 
443
  def restore_embedded_state() -> None:
 
444
  state_backup_root = STATE_DIR / "openclaw"
445
 
446
  # Migration fix: old backups stored state in ".huggingclaw-state/openclaw"
@@ -462,7 +572,7 @@ def restore_embedded_state() -> None:
462
  if state_backup_root.is_dir():
463
  for source_path in state_backup_root.iterdir():
464
  name = source_path.name
465
- if name in EXCLUDED_STATE_NAMES:
466
  if source_path.is_dir():
467
  shutil.rmtree(source_path, ignore_errors=True)
468
  else:
@@ -527,7 +637,7 @@ def ensure_repo_exists() -> str:
527
 
528
  def _should_exclude(rel_posix: str, path: Path) -> bool:
529
  parts = Path(rel_posix).parts
530
- if any(part in EXCLUDED_SYNC_DIRS for part in parts):
531
  return True
532
  if path.is_file():
533
  try:
@@ -558,17 +668,12 @@ def metadata_marker(root: Path) -> WorkspaceMarker:
558
  total_size = 0
559
  newest_mtime = 0
560
  metadata_hasher = hashlib.sha256()
561
- for path in sorted(root.rglob("*")):
562
- if not path.is_file():
563
- continue
564
  rel = path.relative_to(root).as_posix()
565
  # BUG FIX: use directory-only exclusion here (not size-based) so that
566
  # deleting a large file changes the marker and triggers a sync/prune
567
  # pass. Large files are still excluded from the upload snapshot via
568
  # _should_exclude; this only controls change-detection.
569
- parts = Path(rel).parts
570
- if any(part in EXCLUDED_SYNC_DIRS for part in parts):
571
- continue
572
  try:
573
  stat = path.stat()
574
  except OSError:
@@ -576,14 +681,17 @@ def metadata_marker(root: Path) -> WorkspaceMarker:
576
  file_count += 1
577
  size = int(stat.st_size)
578
  mtime_ns = int(stat.st_mtime_ns)
 
579
  total_size += size
580
- newest_mtime = max(newest_mtime, mtime_ns)
581
  metadata_hasher.update(rel.encode("utf-8"))
582
  metadata_hasher.update(b"\0")
583
  metadata_hasher.update(str(size).encode("ascii"))
584
  metadata_hasher.update(b"\0")
585
  metadata_hasher.update(str(mtime_ns).encode("ascii"))
586
  metadata_hasher.update(b"\0")
 
 
587
  return (file_count, total_size, newest_mtime, metadata_hasher.hexdigest())
588
 
589
 
@@ -592,15 +700,12 @@ def fingerprint_dir(root: Path) -> str:
592
  if not root.exists():
593
  return hasher.hexdigest()
594
 
595
- for path in sorted(p for p in root.rglob("*") if p.is_file()):
596
  rel = path.relative_to(root).as_posix()
597
  # BUG FIX: use directory-only exclusion (not size-based) so that
598
  # creating or deleting a large file changes the fingerprint.
599
  # Large files are hashed by path + metadata only (no content read)
600
  # to avoid reading gigabytes for a change-detection hash.
601
- parts = Path(rel).parts
602
- if any(part in EXCLUDED_SYNC_DIRS for part in parts):
603
- continue
604
  hasher.update(rel.encode("utf-8"))
605
  try:
606
  stat = path.stat()
@@ -628,7 +733,7 @@ def fingerprint_dir(root: Path) -> str:
628
 
629
  def create_snapshot_dir(source_root: Path) -> Path:
630
  staging_root = Path(tempfile.mkdtemp(prefix="huggingclaw-sync-"))
631
- for path in sorted(source_root.rglob("*")):
632
  rel = path.relative_to(source_root)
633
  rel_posix = rel.as_posix()
634
  if _should_exclude(rel_posix, path):
@@ -679,15 +784,19 @@ def prune_remote_deleted_files(
679
  and path not in protected_paths
680
  and not any(path == prefix or path.startswith(prefix + "/") for prefix in skip_prefixes)
681
  ]
682
- if not stale_files:
 
 
 
 
683
  return
684
 
685
- total = len(stale_files)
686
  num_batches = (total + PRUNE_BATCH_SIZE - 1) // PRUNE_BATCH_SIZE
687
  for batch_idx in range(num_batches):
688
- batch = stale_files[batch_idx * PRUNE_BATCH_SIZE:(batch_idx + 1) * PRUNE_BATCH_SIZE]
689
  batch_label = f"{batch_idx + 1}/{num_batches}" if num_batches > 1 else ""
690
- msg = f"Prune {len(batch)} stale file(s) after workspace sync"
691
  if batch_label:
692
  msg += f" (batch {batch_label})"
693
  operations = [CommitOperationDelete(path_in_repo=path) for path in batch]
@@ -698,7 +807,25 @@ def prune_remote_deleted_files(
698
  commit_message=msg,
699
  )
700
  if num_batches > 1:
701
- print(f"Pruned batch {batch_label}: {len(batch)} file(s)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
 
703
 
704
  def restore_workspace() -> bool:
@@ -795,10 +922,20 @@ def _sync_once_unlocked(
795
  write_status("disabled", "HF_TOKEN is not configured.")
796
  return (last_fingerprint or "", last_marker or (0, 0, 0, ""))
797
 
798
- global _prune_needed
799
 
800
  had_snapshot_copy_failures = snapshot_state_into_workspace()
801
  repo_id = ensure_repo_exists()
 
 
 
 
 
 
 
 
 
 
802
  current_marker = metadata_marker(WORKSPACE)
803
  # Session watcher uses content digests and can detect same-size rewrites
804
  # whose workspace metadata marker is unchanged. In that case, force the
@@ -879,7 +1016,18 @@ def sync_once(
879
  ) -> tuple[str, WorkspaceMarker]:
880
  SYNC_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
881
  with SYNC_LOCK_FILE.open("w", encoding="utf-8") as lock_handle:
882
- fcntl.flock(lock_handle, fcntl.LOCK_EX)
 
 
 
 
 
 
 
 
 
 
 
883
  try:
884
  return _sync_once_unlocked(
885
  last_fingerprint,
 
51
  float(os.environ.get("OPENCLAW_CONFIG_SETTLE_SECONDS", "3")),
52
  )
53
  SESSIONS_MIN_SYNC_GAP = int(os.environ.get("SESSIONS_MIN_SYNC_GAP", "30"))
54
+ SYNC_LOCK_TIMEOUT = max(1.0, float(os.environ.get("SYNC_LOCK_TIMEOUT", "20")))
55
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
56
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
57
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
 
89
  # "WhatsApp enabled but not installing on restart".
90
  "extensions",
91
  }
92
+ # Internal restore/snapshot working directories live beside the workspace under
93
+ # /home/node/.openclaw. If a previous container is killed mid-restore, these
94
+ # hidden directories can be left behind and may contain a full copy of the
95
+ # workspace, including huggingclaw-state/openclaw itself. Backing them up makes
96
+ # the dataset grow recursively under huggingclaw-state/openclaw and can keep the
97
+ # sync loop busy forever, which in turn blocks session deletes/updates from being
98
+ # pruned remotely. Treat every such helper path as disposable sync scratch.
99
+ INTERNAL_TEMP_STATE_NAMES = {
100
+ ".workspace-restore-staging",
101
+ ".workspace-restore-old",
102
+ ".openclaw-staging",
103
+ }
104
+ INTERNAL_TEMP_NAME_PREFIXES = (
105
+ ".workspace-restore-",
106
+ ".openclaw-staging",
107
+ )
108
  SESSIONS_ROOT = OPENCLAW_HOME / "agents"
109
  WHATSAPP_CREDS_DIR = OPENCLAW_HOME / "credentials" / "whatsapp" / "default"
110
  WHATSAPP_BACKUP_DIR = STATE_DIR / "credentials" / "whatsapp" / "default"
 
117
  # Set True when prune fails so the next sync pass bypasses the fingerprint
118
  # early-exit and retries the prune even if no local files changed.
119
  _prune_needed: bool = False
120
+ _remote_temp_prune_done: bool = False
121
+
122
+ # Workspace-relative temp paths managed by this sync script. Keep this narrow:
123
+ # user projects may legitimately contain folders with similar names, and those
124
+ # must keep syncing normally. Only the script-owned state/restore scratch
125
+ # locations are skipped and pruned.
126
+ INTERNAL_TEMP_WORKSPACE_PREFIXES: tuple[tuple[str, ...], ...] = (
127
+ (".workspace-restore-staging",),
128
+ (".workspace-restore-old",),
129
+ ("huggingclaw-state", ".openclaw-staging"),
130
+ ("huggingclaw-state", "openclaw", ".workspace-restore-staging"),
131
+ ("huggingclaw-state", "openclaw", ".workspace-restore-old"),
132
+ ("huggingclaw-state", "openclaw", ".openclaw-staging"),
133
+ )
134
 
135
 
136
  def write_status(status: str, message: str) -> None:
 
164
  path.unlink(missing_ok=True)
165
 
166
 
167
+ def _is_internal_temp_name(name: str) -> bool:
168
+ return name in INTERNAL_TEMP_STATE_NAMES or any(
169
+ name.startswith(prefix) for prefix in INTERNAL_TEMP_NAME_PREFIXES
170
+ )
171
+
172
+
173
+ def _has_internal_temp_part(path: str) -> bool:
174
+ parts = Path(path).parts
175
+ return any(_matches_prefix(parts, prefix) for prefix in INTERNAL_TEMP_WORKSPACE_PREFIXES)
176
+
177
+
178
+ def _matches_prefix(parts: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
179
+ return len(parts) >= len(prefix) and parts[:len(prefix)] == prefix
180
+
181
+
182
+ def _should_skip_state_entry_name(name: str) -> bool:
183
+ return (
184
+ name in EXCLUDED_STATE_NAMES
185
+ or name in EXCLUDED_SYNC_DIRS
186
+ or _is_internal_temp_name(name)
187
+ )
188
+
189
+
190
+ def _should_skip_sync_path(rel_parts: tuple[str, ...]) -> bool:
191
+ return any(part in EXCLUDED_SYNC_DIRS for part in rel_parts) or any(
192
+ _matches_prefix(rel_parts, prefix) for prefix in INTERNAL_TEMP_WORKSPACE_PREFIXES
193
+ )
194
+
195
+
196
+ def _iter_sync_tree(root: Path):
197
+ """Yield syncable paths without descending into ignored/temp directories."""
198
+ if not root.exists():
199
+ return
200
+
201
+ for dirpath, dirnames, filenames in os.walk(root):
202
+ dir_path = Path(dirpath)
203
+ try:
204
+ dir_rel_parts = dir_path.relative_to(root).parts
205
+ except ValueError:
206
+ dir_rel_parts = ()
207
+ if dir_rel_parts == (".",):
208
+ dir_rel_parts = ()
209
+ dirnames[:] = sorted(
210
+ name for name in dirnames
211
+ if not _should_skip_sync_path(dir_rel_parts + (name,))
212
+ )
213
+
214
+ for dirname in dirnames:
215
+ yield dir_path / dirname
216
+ for filename in sorted(filenames):
217
+ if _should_skip_sync_path(dir_rel_parts + (filename,)):
218
+ continue
219
+ yield dir_path / filename
220
+
221
+
222
+ def _iter_sync_files(root: Path):
223
+ for path in _iter_sync_tree(root):
224
+ if path.is_file():
225
+ yield path
226
+
227
+
228
+ def cleanup_internal_temp_paths() -> None:
229
+ """Remove stale sync/restore scratch dirs that must never be backed up."""
230
+ for root in (OPENCLAW_HOME, STATE_DIR):
231
+ if not root.exists() or not root.is_dir():
232
+ continue
233
+ try:
234
+ children = list(root.iterdir())
235
+ except OSError:
236
+ continue
237
+ for child in children:
238
+ if not _is_internal_temp_name(child.name):
239
+ continue
240
+ try:
241
+ _remove_path(child)
242
+ print(f"Removed stale sync scratch path: {child}")
243
+ except OSError as exc:
244
+ print(f"Warning: could not remove stale sync scratch path {child}: {exc}")
245
+
246
+
247
  def _copy_hot_directory_snapshot(source_path: Path, tmp_path: Path) -> bool:
248
  """Best-effort mirror of a hot directory into *tmp_path*.
249
 
 
370
  def snapshot_state_into_workspace() -> bool:
371
  had_copy_failures = False
372
  try:
373
+ cleanup_internal_temp_paths()
374
  STATE_DIR.mkdir(parents=True, exist_ok=True)
375
  # Atomic snapshot: copy to a staging dir first, then rename.
376
  # This prevents a half-written (or empty) backup if we crash mid-copy,
 
386
  skipped_entries: list[tuple[str, Exception]] = []
387
  copied_entry_names: set[str] = set()
388
  for source_path in OPENCLAW_HOME.iterdir():
389
+ if _should_skip_state_entry_name(source_path.name):
390
  continue
391
 
392
  backup_path = staging_dir / source_path.name
 
412
  # dataset on the next sync. Only remove an entry from staging when the
413
  # source has genuinely been deleted from OPENCLAW_HOME.
414
  for staged_path in list(staging_dir.iterdir()):
415
+ if _should_skip_state_entry_name(staged_path.name):
416
+ if staged_path.exists() or staged_path.is_symlink():
417
+ _remove_path(staged_path)
418
  continue
419
  if staged_path.name in copied_entry_names:
420
  continue
 
539
  protected: set[str] = set()
540
  if not root.exists():
541
  return protected
542
+ for path in _iter_sync_files(root):
 
 
543
  rel = path.relative_to(root).as_posix()
 
 
 
544
  try:
545
  if path.stat().st_size > MAX_FILE_SIZE_BYTES:
546
  protected.add(rel)
 
550
 
551
 
552
  def restore_embedded_state() -> None:
553
+ cleanup_internal_temp_paths()
554
  state_backup_root = STATE_DIR / "openclaw"
555
 
556
  # Migration fix: old backups stored state in ".huggingclaw-state/openclaw"
 
572
  if state_backup_root.is_dir():
573
  for source_path in state_backup_root.iterdir():
574
  name = source_path.name
575
+ if _should_skip_state_entry_name(name):
576
  if source_path.is_dir():
577
  shutil.rmtree(source_path, ignore_errors=True)
578
  else:
 
637
 
638
  def _should_exclude(rel_posix: str, path: Path) -> bool:
639
  parts = Path(rel_posix).parts
640
+ if _should_skip_sync_path(parts):
641
  return True
642
  if path.is_file():
643
  try:
 
668
  total_size = 0
669
  newest_mtime = 0
670
  metadata_hasher = hashlib.sha256()
671
+ for path in _iter_sync_files(root):
 
 
672
  rel = path.relative_to(root).as_posix()
673
  # BUG FIX: use directory-only exclusion here (not size-based) so that
674
  # deleting a large file changes the marker and triggers a sync/prune
675
  # pass. Large files are still excluded from the upload snapshot via
676
  # _should_exclude; this only controls change-detection.
 
 
 
677
  try:
678
  stat = path.stat()
679
  except OSError:
 
681
  file_count += 1
682
  size = int(stat.st_size)
683
  mtime_ns = int(stat.st_mtime_ns)
684
+ ctime_ns = int(stat.st_ctime_ns)
685
  total_size += size
686
+ newest_mtime = max(newest_mtime, mtime_ns, ctime_ns)
687
  metadata_hasher.update(rel.encode("utf-8"))
688
  metadata_hasher.update(b"\0")
689
  metadata_hasher.update(str(size).encode("ascii"))
690
  metadata_hasher.update(b"\0")
691
  metadata_hasher.update(str(mtime_ns).encode("ascii"))
692
  metadata_hasher.update(b"\0")
693
+ metadata_hasher.update(str(ctime_ns).encode("ascii"))
694
+ metadata_hasher.update(b"\0")
695
  return (file_count, total_size, newest_mtime, metadata_hasher.hexdigest())
696
 
697
 
 
700
  if not root.exists():
701
  return hasher.hexdigest()
702
 
703
+ for path in _iter_sync_files(root):
704
  rel = path.relative_to(root).as_posix()
705
  # BUG FIX: use directory-only exclusion (not size-based) so that
706
  # creating or deleting a large file changes the fingerprint.
707
  # Large files are hashed by path + metadata only (no content read)
708
  # to avoid reading gigabytes for a change-detection hash.
 
 
 
709
  hasher.update(rel.encode("utf-8"))
710
  try:
711
  stat = path.stat()
 
733
 
734
  def create_snapshot_dir(source_root: Path) -> Path:
735
  staging_root = Path(tempfile.mkdtemp(prefix="huggingclaw-sync-"))
736
+ for path in _iter_sync_tree(source_root):
737
  rel = path.relative_to(source_root)
738
  rel_posix = rel.as_posix()
739
  if _should_exclude(rel_posix, path):
 
784
  and path not in protected_paths
785
  and not any(path == prefix or path.startswith(prefix + "/") for prefix in skip_prefixes)
786
  ]
787
+ _commit_delete_batches(repo_id, stale_files, "Prune", "stale file(s) after workspace sync")
788
+
789
+
790
+ def _commit_delete_batches(repo_id: str, paths: list[str], action: str, reason: str) -> None:
791
+ if not paths:
792
  return
793
 
794
+ total = len(paths)
795
  num_batches = (total + PRUNE_BATCH_SIZE - 1) // PRUNE_BATCH_SIZE
796
  for batch_idx in range(num_batches):
797
+ batch = paths[batch_idx * PRUNE_BATCH_SIZE:(batch_idx + 1) * PRUNE_BATCH_SIZE]
798
  batch_label = f"{batch_idx + 1}/{num_batches}" if num_batches > 1 else ""
799
+ msg = f"{action} {len(batch)} {reason}"
800
  if batch_label:
801
  msg += f" (batch {batch_label})"
802
  operations = [CommitOperationDelete(path_in_repo=path) for path in batch]
 
807
  commit_message=msg,
808
  )
809
  if num_batches > 1:
810
+ print(f"{action} batch {batch_label}: {len(batch)} file(s)")
811
+
812
+
813
+ def prune_remote_internal_temp_files(repo_id: str) -> None:
814
+ """Remove old recursive scratch files from the HF backup even if nothing changed locally."""
815
+ if HF_API is None:
816
+ return
817
+
818
+ remote_files = list(HF_API.list_repo_files(repo_id=repo_id, repo_type="dataset"))
819
+ internal_temp_files = [
820
+ path for path in remote_files
821
+ if path != ".gitattributes" and _has_internal_temp_part(path)
822
+ ]
823
+ _commit_delete_batches(
824
+ repo_id,
825
+ internal_temp_files,
826
+ "Prune internal sync scratch",
827
+ "file(s) from backup",
828
+ )
829
 
830
 
831
  def restore_workspace() -> bool:
 
922
  write_status("disabled", "HF_TOKEN is not configured.")
923
  return (last_fingerprint or "", last_marker or (0, 0, 0, ""))
924
 
925
+ global _prune_needed, _remote_temp_prune_done
926
 
927
  had_snapshot_copy_failures = snapshot_state_into_workspace()
928
  repo_id = ensure_repo_exists()
929
+ if not _remote_temp_prune_done:
930
+ try:
931
+ prune_remote_internal_temp_files(repo_id)
932
+ _remote_temp_prune_done = True
933
+ except Exception as temp_prune_exc:
934
+ # Do not block normal user-data sync if the cleanup commit fails;
935
+ # leave the flag false so the next pass retries before taking the
936
+ # metadata/fingerprint early exit again.
937
+ print(f"Warning: could not prune internal sync scratch files: {temp_prune_exc}")
938
+ _prune_needed = True
939
  current_marker = metadata_marker(WORKSPACE)
940
  # Session watcher uses content digests and can detect same-size rewrites
941
  # whose workspace metadata marker is unchanged. In that case, force the
 
1016
  ) -> tuple[str, WorkspaceMarker]:
1017
  SYNC_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
1018
  with SYNC_LOCK_FILE.open("w", encoding="utf-8") as lock_handle:
1019
+ deadline = time.monotonic() + SYNC_LOCK_TIMEOUT
1020
+ while True:
1021
+ try:
1022
+ fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
1023
+ break
1024
+ except BlockingIOError:
1025
+ if time.monotonic() >= deadline:
1026
+ raise TimeoutError(
1027
+ f"Timed out waiting {SYNC_LOCK_TIMEOUT:.0f}s for workspace sync lock."
1028
+ )
1029
+ if STOP_EVENT.wait(0.25):
1030
+ raise TimeoutError("Stopped while waiting for workspace sync lock.")
1031
  try:
1032
  return _sync_once_unlocked(
1033
  last_fingerprint,
start.sh CHANGED
@@ -657,7 +657,7 @@ _DEFAULT_XAI_MODELS="xai/grok-4.20,xai/grok-4.3,xai/grok-4.1,xai/grok-latest,xai
657
  _DEFAULT_COHERE_MODELS="cohere/command-a,cohere/command-a-03-2025,cohere/command-a-reasoning-08-2025,cohere/command-r-plus-08-2024"
658
  _DEFAULT_TOGETHER_MODELS="together/moonshotai/Kimi-K2.6,together/deepseek-ai/DeepSeek-V4-Pro,together/Qwen/Qwen3-235B-A22B-Instruct-2507-tput,together/meta-llama/Llama-3.3-70B-Instruct-Turbo"
659
  _DEFAULT_CEREBRAS_MODELS="cerebras/zai-glm-4.7,cerebras/gpt-oss-120b,cerebras/deepseek-r1,cerebras/qwen3-32b"
660
- _DEFAULT_NVIDIA_MODELS="nvidia/nemotron-3-super-120b-a12b,nvidia/nemotron-4-340b-instruct,nvidia/llama-3.1-nemotron-70b-instruct"
661
  _DEFAULT_KILOCODE_MODELS="kilocode/kilo/auto,kilocode/anthropic/claude-opus-4.7,kilocode/openai/gpt-5.4,kilocode/google/gemini-2.5-pro"
662
  _DEFAULT_MOONSHOT_MODELS="moonshot/kimi-k2.6,moonshot/kimi-k2.6-thinking,moonshot/kimi-k2-thinking"
663
  _DEFAULT_MINIMAX_MODELS="minimax/MiniMax-M2.7,minimax/minimax-m1.5"
@@ -1414,32 +1414,63 @@ if [ -n "${WEBHOOK_URL:-}" ]; then
1414
  fi
1415
 
1416
  # ── Trap SIGTERM for graceful shutdown ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1417
  graceful_shutdown() {
1418
  echo "Shutting down..."
1419
  if [ -f "/home/node/app/openclaw-sync.py" ] && [ -n "${HF_TOKEN:-}" ]; then
1420
  echo "Saving state before exit..."
1421
- # BUG FIX: kill the background sync loop *before* running the shutdown
1422
- # syncs. The loop holds the sync lock while uploading; if it is
1423
- # mid-upload when sync-once-settled runs, the 8-second timeout fires
1424
- # before the lock is released and the settled-sync is silently skipped.
1425
- # Killing the loop first releases the lock immediately (fcntl.flock is
1426
- # released on process exit) so sync-once-settled can acquire it cleanly.
1427
- if [ -n "${SYNC_LOOP_PID:-}" ]; then
1428
- kill "$SYNC_LOOP_PID" 2>/dev/null || true
1429
- # Give Python a moment to flush and release the lock file.
1430
- # Reduced from 0.5 s β†’ 0.3 s to reclaim time for the upload.
1431
- sleep 0.3
1432
- fi
1433
  # Pass 1: wait for config to settle then upload β€” avoids pushing a
1434
- # half-written JSON config to the dataset. Timeout raised from 8 s
1435
- # to 15 s so the 3-second settle window + actual upload both fit
1436
- # within the budget (old 8 s left only 5 s for the upload itself).
1437
- timeout 15s python3 /home/node/app/openclaw-sync.py sync-once-settled || \
1438
  echo "Warning: could not complete settled shutdown sync"
1439
  # Pass 2: catch any writes that arrived after the settled sync completed.
1440
  # BUG FIX: added timeout (previously unbounded) so HF container kill
1441
  # can't interrupt a hung upload and lose all data silently.
1442
- timeout 8s python3 /home/node/app/openclaw-sync.py sync-once || \
1443
  echo "Warning: could not complete final shutdown sync"
1444
  elif [ -f "/home/node/app/openclaw-sync.py" ]; then
1445
  echo "HF_TOKEN not set; skipping shutdown backup sync."
@@ -2439,27 +2470,21 @@ sync_before_gateway_restart() {
2439
 
2440
  echo "Gateway stopped; saving latest OpenClaw state before restart..."
2441
  # Kill the background sync loop before syncing β€” same reason as in
2442
- # graceful_shutdown: the loop holds the fcntl.flock while uploading;
2443
- # if it is mid-upload when sync-once-settled runs, the lock contention
2444
- # will eat into (or exhaust) the 15s timeout budget, silently skipping
2445
- # the upload. Killing the loop releases the lock immediately (fcntl.flock
2446
- # is released on process exit) so sync-once-settled can acquire it cleanly.
2447
- if [ -n "${SYNC_LOOP_PID:-}" ]; then
2448
- kill "$SYNC_LOOP_PID" 2>/dev/null || true
2449
- sleep 0.3
2450
- SYNC_LOOP_PID=""
2451
- fi
2452
  # Pass 1: wait for config to settle then upload β€” avoids pushing a
2453
  # half-written JSON config to the dataset. Timeout added (was unbounded)
2454
  # so a slow HF upload cannot stall gateway restarts indefinitely.
2455
- timeout 15s python3 /home/node/app/openclaw-sync.py sync-once-settled || \
2456
  echo "Warning: could not sync settled state before gateway restart"
2457
  # Pass 2: catch any writes that arrived after the settled sync completed
2458
  # (e.g. session state flushed by OpenClaw just before exit).
2459
  # BUG FIX: this second pass was missing from the restart path (it existed
2460
  # only in graceful_shutdown), so last-second writes were lost on watchdog
2461
  # restarts β€” causing sessions to disappear after OpenClaw auto-restarts.
2462
- timeout 8s python3 /home/node/app/openclaw-sync.py sync-once || \
2463
  echo "Warning: could not complete final sync before gateway restart"
2464
  }
2465
 
 
657
  _DEFAULT_COHERE_MODELS="cohere/command-a,cohere/command-a-03-2025,cohere/command-a-reasoning-08-2025,cohere/command-r-plus-08-2024"
658
  _DEFAULT_TOGETHER_MODELS="together/moonshotai/Kimi-K2.6,together/deepseek-ai/DeepSeek-V4-Pro,together/Qwen/Qwen3-235B-A22B-Instruct-2507-tput,together/meta-llama/Llama-3.3-70B-Instruct-Turbo"
659
  _DEFAULT_CEREBRAS_MODELS="cerebras/zai-glm-4.7,cerebras/gpt-oss-120b,cerebras/deepseek-r1,cerebras/qwen3-32b"
660
+ _DEFAULT_NVIDIA_MODELS="nvidia/nemotron-3-super-120b-a12b,nvidia/nemotron-4-340b-instruct,nvidia/llama-3.1-nemotron-70b-instruct,nvidia/stepfun-ai/step-3.7-flash"
661
  _DEFAULT_KILOCODE_MODELS="kilocode/kilo/auto,kilocode/anthropic/claude-opus-4.7,kilocode/openai/gpt-5.4,kilocode/google/gemini-2.5-pro"
662
  _DEFAULT_MOONSHOT_MODELS="moonshot/kimi-k2.6,moonshot/kimi-k2.6-thinking,moonshot/kimi-k2-thinking"
663
  _DEFAULT_MINIMAX_MODELS="minimax/MiniMax-M2.7,minimax/minimax-m1.5"
 
1414
  fi
1415
 
1416
  # ── Trap SIGTERM for graceful shutdown ──
1417
+ stop_background_sync_loop() {
1418
+ [ -n "${SYNC_LOOP_PID:-}" ] || return 0
1419
+
1420
+ if ! kill -0 "$SYNC_LOOP_PID" 2>/dev/null; then
1421
+ SYNC_LOOP_PID=""
1422
+ return 0
1423
+ fi
1424
+
1425
+ kill "$SYNC_LOOP_PID" 2>/dev/null || true
1426
+ # Wait for the Python process to actually exit so its fcntl lock is released.
1427
+ # A fixed short sleep was not enough when the process was inside a HF upload,
1428
+ # which made the following one-shot syncs burn their timeout waiting on the
1429
+ # lock and left sessions/config changes unsaved.
1430
+ for _sync_stop_i in 1 2 3 4 5 6 7 8 9 10; do
1431
+ if ! kill -0 "$SYNC_LOOP_PID" 2>/dev/null; then
1432
+ SYNC_LOOP_PID=""
1433
+ unset _sync_stop_i
1434
+ return 0
1435
+ fi
1436
+ sleep 0.2
1437
+ done
1438
+
1439
+ echo "Warning: workspace sync loop did not stop after SIGTERM; forcing it to release the sync lock."
1440
+ kill -9 "$SYNC_LOOP_PID" 2>/dev/null || true
1441
+ sleep 0.2
1442
+ SYNC_LOOP_PID=""
1443
+ unset _sync_stop_i
1444
+ }
1445
+
1446
+ run_openclaw_sync_with_timeout() {
1447
+ local timeout_seconds="$1"
1448
+ local command_name="$2"
1449
+ local lock_timeout_seconds="$3"
1450
+ if [ "${timeout_seconds}" = "0" ]; then
1451
+ env SYNC_LOCK_TIMEOUT="$lock_timeout_seconds" \
1452
+ python3 /home/node/app/openclaw-sync.py "$command_name"
1453
+ else
1454
+ timeout --kill-after=10s "${timeout_seconds}s" env SYNC_LOCK_TIMEOUT="$lock_timeout_seconds" \
1455
+ python3 /home/node/app/openclaw-sync.py "$command_name"
1456
+ fi
1457
+ }
1458
+
1459
  graceful_shutdown() {
1460
  echo "Shutting down..."
1461
  if [ -f "/home/node/app/openclaw-sync.py" ] && [ -n "${HF_TOKEN:-}" ]; then
1462
  echo "Saving state before exit..."
1463
+ stop_background_sync_loop
 
 
 
 
 
 
 
 
 
 
 
1464
  # Pass 1: wait for config to settle then upload β€” avoids pushing a
1465
+ # half-written JSON config to the dataset. The per-command lock wait is
1466
+ # intentionally shorter than the outer timeout so a stuck lock fails clearly
1467
+ # and leaves time for the final catch-up pass.
1468
+ run_openclaw_sync_with_timeout "${SYNC_SETTLED_TIMEOUT:-120}" sync-once-settled "${SYNC_ONE_SHOT_LOCK_TIMEOUT:-5}" || \
1469
  echo "Warning: could not complete settled shutdown sync"
1470
  # Pass 2: catch any writes that arrived after the settled sync completed.
1471
  # BUG FIX: added timeout (previously unbounded) so HF container kill
1472
  # can't interrupt a hung upload and lose all data silently.
1473
+ run_openclaw_sync_with_timeout "${SYNC_FINAL_TIMEOUT:-120}" sync-once "${SYNC_ONE_SHOT_LOCK_TIMEOUT:-5}" || \
1474
  echo "Warning: could not complete final shutdown sync"
1475
  elif [ -f "/home/node/app/openclaw-sync.py" ]; then
1476
  echo "HF_TOKEN not set; skipping shutdown backup sync."
 
2470
 
2471
  echo "Gateway stopped; saving latest OpenClaw state before restart..."
2472
  # Kill the background sync loop before syncing β€” same reason as in
2473
+ # graceful_shutdown: the loop holds the fcntl.flock while uploading. Wait for
2474
+ # it to exit (and force-kill as a last resort) before the one-shot syncs so
2475
+ # gateway restarts cannot hang behind a stale lock.
2476
+ stop_background_sync_loop
 
 
 
 
 
 
2477
  # Pass 1: wait for config to settle then upload β€” avoids pushing a
2478
  # half-written JSON config to the dataset. Timeout added (was unbounded)
2479
  # so a slow HF upload cannot stall gateway restarts indefinitely.
2480
+ run_openclaw_sync_with_timeout "${SYNC_SETTLED_TIMEOUT:-120}" sync-once-settled "${SYNC_ONE_SHOT_LOCK_TIMEOUT:-5}" || \
2481
  echo "Warning: could not sync settled state before gateway restart"
2482
  # Pass 2: catch any writes that arrived after the settled sync completed
2483
  # (e.g. session state flushed by OpenClaw just before exit).
2484
  # BUG FIX: this second pass was missing from the restart path (it existed
2485
  # only in graceful_shutdown), so last-second writes were lost on watchdog
2486
  # restarts β€” causing sessions to disappear after OpenClaw auto-restarts.
2487
+ run_openclaw_sync_with_timeout "${SYNC_FINAL_TIMEOUT:-120}" sync-once "${SYNC_ONE_SHOT_LOCK_TIMEOUT:-5}" || \
2488
  echo "Warning: could not complete final sync before gateway restart"
2489
  }
2490