Spaces:
Building
Building
Anurag commited on
Commit Β·
d02beec
1
Parent(s): e1afa1b
Add NVIDIA StepFun flash model
Browse files- .env.example +18 -1
- README.md +4 -0
- env-builder.js +4 -2
- jupyter-devdata-sync.py +46 -5
- openclaw-sync.py +177 -29
- 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
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
return
|
| 684 |
|
| 685 |
-
total = len(
|
| 686 |
num_batches = (total + PRUNE_BATCH_SIZE - 1) // PRUNE_BATCH_SIZE
|
| 687 |
for batch_idx in range(num_batches):
|
| 688 |
-
batch =
|
| 689 |
batch_label = f"{batch_idx + 1}/{num_batches}" if num_batches > 1 else ""
|
| 690 |
-
msg = f"
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 1435 |
-
#
|
| 1436 |
-
#
|
| 1437 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 2444 |
-
#
|
| 2445 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
|