nexo-brain 2.5.1 → 2.6.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.
@@ -1,23 +1,26 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- NEXO Catch-Up — Runs at boot/wake to recover any missed scheduled tasks.
4
-
5
- Tasks are loaded dynamically from crons/manifest.json (single source of truth).
6
- Only scheduled crons (with hour/minute) are recovered — interval-based crons
7
- (immune, watchdog, auto-close) restart automatically via launchd/systemd.
2
+ """NEXO Catch-Up — recover missed core cron windows after boot/wake.
8
3
 
9
- Logic: For each scheduled task, check if its last successful run was before
10
- the most recent scheduled time. If so, run it now. Only marks success on exit 0.
11
- Uses cron/launchd weekday convention (0=Sunday) converted to Python (0=Monday).
4
+ Recovery is driven by the explicit manifest contract plus cron_runs.
5
+ Legacy .catchup-state.json is now only a fallback for pre-wrapper history.
12
6
  """
13
7
 
8
+ import fcntl
14
9
  import json
15
10
  import os
16
11
  import subprocess
17
12
  import sys
18
- from datetime import datetime, timedelta
13
+ from datetime import datetime
19
14
  from pathlib import Path
20
15
 
16
+ _SCRIPT_DIR = Path(__file__).resolve().parent
17
+ _DEFAULT_RUNTIME_ROOT = _SCRIPT_DIR.parent
18
+ _runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
19
+ if str(_runtime_root) not in sys.path:
20
+ sys.path.insert(0, str(_runtime_root))
21
+
22
+ from cron_recovery import catchup_candidates
23
+
21
24
  HOME = Path.home()
22
25
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(HOME / ".nexo")))
23
26
 
@@ -48,8 +51,10 @@ LOG_DIR = NEXO_HOME / "logs"
48
51
  LOG_DIR.mkdir(parents=True, exist_ok=True)
49
52
  LOG_FILE = LOG_DIR / "catchup.log"
50
53
  STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
54
+ LOCK_FILE = NEXO_HOME / "operations" / ".catchup.lock"
51
55
 
52
56
  SCRIPTS = NEXO_HOME / "scripts"
57
+ WRAPPER = SCRIPTS / "nexo-cron-wrapper.sh"
53
58
 
54
59
  # Resolve Python: prefer NEXO's venv, then the same Python running this script
55
60
  def _resolve_python() -> str:
@@ -68,52 +73,6 @@ def _resolve_python() -> str:
68
73
  return sys.executable
69
74
 
70
75
  NEXO_PYTHON = _resolve_python()
71
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
72
- # Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
73
- _manifest_home = NEXO_HOME / "crons" / "manifest.json"
74
- _manifest_code = NEXO_CODE / "crons" / "manifest.json"
75
- MANIFEST = _manifest_home if _manifest_home.exists() else _manifest_code
76
-
77
-
78
- def _load_tasks_from_manifest() -> list[tuple]:
79
- """Read scheduled tasks from manifest.json — single source of truth.
80
-
81
- Only includes crons with a schedule (hour/minute). Excludes interval-based
82
- crons (immune, watchdog, auto-close) and run_at_load (catchup itself).
83
- Returns: list of (name, hour, minute, python_or_bash, script, weekday)
84
- """
85
- if not MANIFEST.exists():
86
- log(f"WARNING: manifest not found at {MANIFEST}, using empty task list")
87
- return []
88
-
89
- with open(MANIFEST) as f:
90
- data = json.load(f)
91
-
92
- tasks = []
93
- for cron in data.get("crons", []):
94
- schedule = cron.get("schedule")
95
- if not schedule or "hour" not in schedule:
96
- continue # Skip interval-based and run_at_load crons
97
- if cron["id"] == "catchup":
98
- continue # Don't catch up ourselves
99
-
100
- script = cron["script"]
101
- script_type = cron.get("type", "python")
102
- interpreter = NEXO_PYTHON if script_type == "python" else "/bin/bash"
103
- weekday = schedule.get("weekday")
104
-
105
- tasks.append((
106
- cron["id"],
107
- schedule["hour"],
108
- schedule["minute"],
109
- interpreter,
110
- Path(script).name,
111
- weekday,
112
- ))
113
-
114
- # Sort by hour, minute for correct execution order
115
- tasks.sort(key=lambda t: (t[1], t[2]))
116
- return tasks
117
76
 
118
77
 
119
78
  def log(msg: str):
@@ -138,59 +97,48 @@ def save_state(state: dict):
138
97
  STATE_FILE.write_text(json.dumps(state, indent=2))
139
98
 
140
99
 
141
- def last_scheduled_time(hour: int, minute: int, weekday: int = None) -> datetime:
142
- """Calculate the most recent time this task should have run."""
143
- now = datetime.now()
144
- today_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
145
-
146
- if weekday is not None:
147
- # Weekly task — find the most recent matching weekday
148
- # Manifest uses cron/launchd convention: 0=Sunday, 6=Saturday
149
- # Python datetime.weekday() uses: 0=Monday, 6=Sunday
150
- # Convert: manifest 0 (Sun) -> python 6, manifest 1 (Mon) -> python 0, etc.
151
- py_weekday = (weekday - 1) % 7
152
- days_since = (now.weekday() - py_weekday) % 7
153
- target = now - timedelta(days=days_since)
154
- target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
155
- if target > now:
156
- target -= timedelta(weeks=1)
157
- return target
158
-
159
- # Daily task
160
- if today_at <= now:
161
- return today_at
162
- else:
163
- return today_at - timedelta(days=1)
164
-
100
+ def _resolve_runtime_command(script_type: str) -> str:
101
+ if script_type == "shell":
102
+ return "/bin/bash"
103
+ if script_type == "node":
104
+ return "node"
105
+ if script_type == "php":
106
+ return "php"
107
+ return NEXO_PYTHON
165
108
 
166
- def should_run(task_name: str, hour: int, minute: int, state: dict, weekday: int = None) -> bool:
167
- """Check if task needs catch-up: last run was before last scheduled time."""
168
- last_run_str = state.get(task_name)
169
- last_scheduled = last_scheduled_time(hour, minute, weekday)
170
-
171
- if not last_run_str:
172
- # Never ran — should run
173
- return True
174
109
 
110
+ def _acquire_lock():
111
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
112
+ handle = LOCK_FILE.open("w")
175
113
  try:
176
- last_run = datetime.fromisoformat(last_run_str)
177
- except ValueError:
178
- return True
179
-
180
- return last_run < last_scheduled
114
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
115
+ except BlockingIOError:
116
+ handle.close()
117
+ return None
118
+ handle.write(str(os.getpid()))
119
+ handle.flush()
120
+ return handle
181
121
 
182
122
 
183
- def run_task(name: str, python: str, script: str, state: dict) -> bool:
123
+ def run_task(candidate: dict, state: dict) -> bool:
184
124
  """Execute a task and update state."""
185
- script_path = str(SCRIPTS / script)
125
+ name = candidate["cron_id"]
126
+ script_name = Path(candidate["script"]).name
127
+ script_path = str(SCRIPTS / script_name)
186
128
  if not Path(script_path).exists():
187
129
  log(f" SKIP {name}: script not found ({script_path})")
188
130
  return False
189
131
 
190
- log(f" RUNNING {name}: {script}")
132
+ runtime_cmd = _resolve_runtime_command(candidate.get("type", "python"))
133
+ if WRAPPER.exists():
134
+ command = ["/bin/bash", str(WRAPPER), name, runtime_cmd, script_path]
135
+ else:
136
+ command = [runtime_cmd, script_path]
137
+
138
+ log(f" RUNNING {name}: {script_name}")
191
139
  try:
192
140
  result = subprocess.run(
193
- [python, script_path],
141
+ command,
194
142
  capture_output=True, text=True, timeout=21600,
195
143
  env={**os.environ, "HOME": str(HOME), "NEXO_CATCHUP": "1"}
196
144
  )
@@ -205,7 +153,7 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
205
153
  log(f" stderr: {result.stderr[:300]}")
206
154
  return False
207
155
  except subprocess.TimeoutExpired:
208
- log(f" TIMEOUT {name} (300s)")
156
+ log(f" TIMEOUT {name} (21600s)")
209
157
  return False
210
158
  except Exception as e:
211
159
  log(f" ERROR {name}: {e}")
@@ -214,28 +162,45 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
214
162
 
215
163
  def main():
216
164
  log("=== NEXO Catch-Up starting (boot/wake) ===")
217
- state = load_state()
165
+ lock_handle = _acquire_lock()
166
+ if lock_handle is None:
167
+ log("Catch-Up already running; skipping overlapping invocation.")
168
+ return
218
169
 
219
- # Read tasks from manifest — single source of truth
220
- tasks = _load_tasks_from_manifest()
170
+ state = load_state()
171
+ tasks = catchup_candidates()
221
172
 
222
173
  ran = 0
223
174
  skipped = 0
224
- for name, hour, minute, python, script, weekday in tasks:
225
- if should_run(name, hour, minute, state, weekday):
226
- log(f" {name} missed scheduled run, catching up...")
227
- if run_task(name, python, script, state):
175
+ skipped_out_of_window = 0
176
+ try:
177
+ for candidate in tasks:
178
+ name = candidate["cron_id"]
179
+ if not candidate.get("missed"):
180
+ skipped += 1
181
+ continue
182
+ if not candidate.get("within_window"):
183
+ skipped_out_of_window += 1
184
+ log(
185
+ f" SKIP {name}: missed window is {candidate['age_seconds']}s old "
186
+ f"(max_catchup_age={candidate['contract']['max_catchup_age']}s)"
187
+ )
188
+ continue
189
+ due_at = candidate["last_due_at"].astimezone().strftime("%Y-%m-%d %H:%M")
190
+ log(f" {name} — missed scheduled run due at {due_at}, catching up...")
191
+ if run_task(candidate, state):
228
192
  ran += 1
229
- else:
230
- skipped += 1
193
+ finally:
194
+ lock_handle.close()
231
195
 
232
- if ran == 0:
196
+ if ran == 0 and skipped_out_of_window == 0:
233
197
  log("All tasks up to date, nothing to catch up.")
234
198
  elif ran >= 3:
235
199
  # Many tasks caught up — ask CLI to assess system state
236
200
  _cli_post_catchup_assessment(ran, skipped, state)
237
201
  else:
238
- log(f"Caught up {ran} tasks, {skipped} already current.")
202
+ suffix = f", {skipped_out_of_window} outside recovery window" if skipped_out_of_window else ""
203
+ log(f"Caught up {ran} tasks, {skipped} already current{suffix}.")
239
204
 
240
205
  log("=== Catch-Up complete ===")
241
206
 
@@ -34,17 +34,35 @@ SANDBOX_DIR = CLAUDE_DIR / "sandbox" / "workspace"
34
34
  MAX_CONSECUTIVE_FAILURES = 3
35
35
  MAX_SNAPSHOTS = 8
36
36
 
37
- # ── Immutable files — NEVER touch (applies to ALL modes) ────────────────
38
- IMMUTABLE_FILES = {
39
- "db.py", "server.py", "plugin_loader.py", "nexo-watchdog.sh",
40
- "cortex-wrapper.py", "CLAUDE.md", "personality.md",
41
- "user-profile.md", "evolution_cycle.py",
42
- # Core cognitive engine — never auto-modified
43
- "cognitive.py", "knowledge_graph.py", "storage_router.py",
44
- # Core tools — never auto-modified
45
- "tools_sessions.py", "tools_coordination.py", "tools_reminders.py",
46
- "tools_reminders_crud.py", "tools_learnings.py", "tools_credentials.py",
47
- "tools_task_history.py", "tools_menu.py",
37
+ # ── Immutable files — split by risk tier ────────────────────────────────
38
+ # These remain locked even in managed mode because they can break bootstrap,
39
+ # persistence, or the evolution engine itself.
40
+ GLOBAL_IMMUTABLE_FILES = {
41
+ "db.py",
42
+ "server.py",
43
+ "plugin_loader.py",
44
+ "nexo-watchdog.sh",
45
+ "cortex-wrapper.py",
46
+ "CLAUDE.md",
47
+ "personality.md",
48
+ "user-profile.md",
49
+ "evolution_cycle.py",
50
+ "storage_router.py",
51
+ }
52
+
53
+ # Managed mode may autoevolve behavior/tooling modules, but auto/review keep
54
+ # these guarded to stay conservative for public installs.
55
+ STANDARD_MODE_IMMUTABLE_FILES = {
56
+ "cognitive.py",
57
+ "knowledge_graph.py",
58
+ "tools_sessions.py",
59
+ "tools_coordination.py",
60
+ "tools_reminders.py",
61
+ "tools_reminders_crud.py",
62
+ "tools_learnings.py",
63
+ "tools_credentials.py",
64
+ "tools_task_history.py",
65
+ "tools_menu.py",
48
66
  }
49
67
 
50
68
 
@@ -93,6 +111,13 @@ def _normalize_mode(mode: str) -> str:
93
111
  }
94
112
  return aliases.get(value, value if value in {"auto", "review", "managed"} else "auto")
95
113
 
114
+
115
+ def _immutable_files_for_mode(mode: str) -> set[str]:
116
+ normalized = _normalize_mode(mode)
117
+ if normalized == "managed":
118
+ return set(GLOBAL_IMMUTABLE_FILES)
119
+ return set(GLOBAL_IMMUTABLE_FILES) | set(STANDARD_MODE_IMMUTABLE_FILES)
120
+
96
121
  # ── Claude CLI path ──────────────────────────────────────────────────────
97
122
  def _resolve_claude_cli() -> Path:
98
123
  """Find claude CLI: saved path > PATH > common locations."""
@@ -199,7 +224,7 @@ def is_safe_path(filepath: str, mode: str = "auto") -> bool:
199
224
  filename = Path(expanded).name
200
225
  mode = _normalize_mode(mode)
201
226
 
202
- if filename in IMMUTABLE_FILES:
227
+ if filename in _immutable_files_for_mode(mode):
203
228
  return False
204
229
 
205
230
  prefixes = _managed_safe_prefixes() if mode in {"managed", "review"} else _public_safe_prefixes()