nexo-brain 2.5.1 → 2.6.1
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.
- package/.claude-plugin/plugin.json +33 -0
- package/.mcp.json +12 -0
- package/README.md +38 -26
- package/bin/nexo-brain.js +35 -32
- package/hooks/hooks.json +14 -0
- package/package.json +11 -4
- package/src/auto_update.py +44 -1
- package/src/cli.py +388 -23
- package/src/cron_recovery.py +283 -0
- package/src/crons/manifest.json +79 -21
- package/src/crons/sync.py +136 -31
- package/src/db/__init__.py +11 -0
- package/src/db/_personal_scripts.py +548 -0
- package/src/db/_schema.py +44 -1
- package/src/doctor/providers/runtime.py +272 -75
- package/src/evolution_cycle.py +4 -1
- package/src/nexo.db +0 -0
- package/src/plugins/personal_scripts.py +117 -0
- package/src/plugins/schedule.py +116 -27
- package/src/script_registry.py +877 -28
- package/src/scripts/nexo-catchup.py +74 -109
- package/src/scripts/nexo-evolution-run.py +37 -12
- package/src/scripts/nexo-watchdog.sh +242 -54
- package/src/tools_learnings.py +8 -0
- package/templates/launchagents/com.nexo.catchup.plist +7 -6
- package/templates/script-template.py +3 -0
- package/templates/script-template.sh +13 -0
- package/src/scripts/nexo-day-orchestrator.sh +0 -139
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Shared cron recovery contract for catchup, launchagent sync, and diagnostics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import sqlite3
|
|
8
|
+
import contextlib
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
13
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
14
|
+
LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
|
|
15
|
+
OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
|
|
16
|
+
DB_PATH = NEXO_HOME / "data" / "nexo.db"
|
|
17
|
+
STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _local_timezone():
|
|
21
|
+
return datetime.now().astimezone().tzinfo or timezone.utc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_json(path: Path, default):
|
|
25
|
+
try:
|
|
26
|
+
if path.is_file():
|
|
27
|
+
return json.loads(path.read_text())
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_enabled_crons() -> list[dict]:
|
|
34
|
+
manifest_candidates = [
|
|
35
|
+
NEXO_HOME / "crons" / "manifest.json",
|
|
36
|
+
NEXO_CODE / "crons" / "manifest.json",
|
|
37
|
+
]
|
|
38
|
+
optionals = _load_json(OPTIONALS_FILE, {})
|
|
39
|
+
if not isinstance(optionals, dict):
|
|
40
|
+
optionals = {}
|
|
41
|
+
|
|
42
|
+
for manifest_path in manifest_candidates:
|
|
43
|
+
if not manifest_path.is_file():
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(manifest_path.read_text())
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
enabled = []
|
|
51
|
+
for cron in data.get("crons", []):
|
|
52
|
+
optional_key = cron.get("optional")
|
|
53
|
+
if optional_key and not optionals.get(optional_key, False):
|
|
54
|
+
continue
|
|
55
|
+
enabled.append(dict(cron))
|
|
56
|
+
return enabled
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_recovery_policy(cron: dict) -> str:
|
|
61
|
+
if cron.get("keep_alive") or cron.get("interval_seconds"):
|
|
62
|
+
return "restart"
|
|
63
|
+
if cron.get("schedule"):
|
|
64
|
+
return "catchup"
|
|
65
|
+
return "none"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def default_max_catchup_age(cron: dict) -> int:
|
|
69
|
+
if cron.get("interval_seconds"):
|
|
70
|
+
interval = int(cron["interval_seconds"])
|
|
71
|
+
return max(interval * 4, interval + 900)
|
|
72
|
+
schedule = cron.get("schedule") or {}
|
|
73
|
+
if "weekday" in schedule:
|
|
74
|
+
return 14 * 86400
|
|
75
|
+
if "hour" in schedule and "minute" in schedule:
|
|
76
|
+
return 48 * 3600
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def recovery_contract(cron: dict) -> dict:
|
|
81
|
+
policy = cron.get("recovery_policy") or default_recovery_policy(cron)
|
|
82
|
+
return {
|
|
83
|
+
"recovery_policy": policy,
|
|
84
|
+
"idempotent": bool(cron.get("idempotent", policy in {"catchup", "restart"})),
|
|
85
|
+
"max_catchup_age": int(cron.get("max_catchup_age", default_max_catchup_age(cron)) or 0),
|
|
86
|
+
"run_on_boot": bool(cron.get("run_on_boot", cron.get("run_at_load") or bool(cron.get("interval_seconds")))),
|
|
87
|
+
"run_on_wake": bool(cron.get("run_on_wake", policy == "catchup" or bool(cron.get("interval_seconds")))),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def should_run_at_load(cron: dict) -> bool:
|
|
92
|
+
if cron.get("keep_alive"):
|
|
93
|
+
return True
|
|
94
|
+
if cron.get("run_at_load"):
|
|
95
|
+
return True
|
|
96
|
+
return bool(cron.get("run_on_boot") and cron.get("interval_seconds"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def launchagent_schedule(cron_id: str) -> dict:
|
|
100
|
+
plist_path = LAUNCH_AGENTS_DIR / f"com.nexo.{cron_id}.plist"
|
|
101
|
+
if not plist_path.is_file():
|
|
102
|
+
return {}
|
|
103
|
+
try:
|
|
104
|
+
with plist_path.open("rb") as fh:
|
|
105
|
+
plist_data = plistlib.load(fh)
|
|
106
|
+
except Exception:
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
result = {
|
|
110
|
+
"source": "launchagent",
|
|
111
|
+
"run_at_load": bool(plist_data.get("RunAtLoad")),
|
|
112
|
+
}
|
|
113
|
+
if "StartInterval" in plist_data:
|
|
114
|
+
result["schedule_type"] = "interval"
|
|
115
|
+
result["interval_seconds"] = int(plist_data["StartInterval"])
|
|
116
|
+
return result
|
|
117
|
+
if "StartCalendarInterval" in plist_data:
|
|
118
|
+
result["schedule_type"] = "calendar"
|
|
119
|
+
result["calendar"] = plist_data["StartCalendarInterval"]
|
|
120
|
+
return result
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def effective_schedule(cron: dict) -> dict:
|
|
125
|
+
actual = launchagent_schedule(cron["id"])
|
|
126
|
+
if actual.get("schedule_type"):
|
|
127
|
+
return actual
|
|
128
|
+
|
|
129
|
+
if cron.get("interval_seconds"):
|
|
130
|
+
return {
|
|
131
|
+
"source": "manifest",
|
|
132
|
+
"schedule_type": "interval",
|
|
133
|
+
"interval_seconds": int(cron["interval_seconds"]),
|
|
134
|
+
"run_at_load": should_run_at_load(cron),
|
|
135
|
+
}
|
|
136
|
+
if cron.get("schedule"):
|
|
137
|
+
return {
|
|
138
|
+
"source": "manifest",
|
|
139
|
+
"schedule_type": "calendar",
|
|
140
|
+
"calendar": cron["schedule"],
|
|
141
|
+
"run_at_load": should_run_at_load(cron),
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
"source": "manifest",
|
|
145
|
+
"schedule_type": "manual",
|
|
146
|
+
"run_at_load": should_run_at_load(cron),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_timestamp(value: str, *, assume_utc: bool) -> datetime | None:
|
|
151
|
+
if not value:
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
parsed = datetime.fromisoformat(value)
|
|
155
|
+
except ValueError:
|
|
156
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
|
157
|
+
try:
|
|
158
|
+
parsed = datetime.strptime(value, fmt)
|
|
159
|
+
break
|
|
160
|
+
except ValueError:
|
|
161
|
+
continue
|
|
162
|
+
else:
|
|
163
|
+
return None
|
|
164
|
+
if parsed.tzinfo is None:
|
|
165
|
+
parsed = parsed.replace(tzinfo=timezone.utc if assume_utc else _local_timezone())
|
|
166
|
+
return parsed
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def latest_successful_runs(cron_ids: list[str], *, db_path: Path = DB_PATH) -> dict[str, datetime]:
|
|
170
|
+
if not cron_ids or not db_path.is_file():
|
|
171
|
+
return {}
|
|
172
|
+
conn = None
|
|
173
|
+
try:
|
|
174
|
+
conn = sqlite3.connect(str(db_path), timeout=2)
|
|
175
|
+
conn.row_factory = sqlite3.Row
|
|
176
|
+
placeholders = ",".join("?" for _ in cron_ids)
|
|
177
|
+
rows = conn.execute(
|
|
178
|
+
f"""
|
|
179
|
+
SELECT c1.cron_id, c1.started_at
|
|
180
|
+
FROM cron_runs c1
|
|
181
|
+
JOIN (
|
|
182
|
+
SELECT cron_id, MAX(id) AS max_id
|
|
183
|
+
FROM cron_runs
|
|
184
|
+
WHERE cron_id IN ({placeholders}) AND exit_code = 0
|
|
185
|
+
GROUP BY cron_id
|
|
186
|
+
) latest ON latest.max_id = c1.id
|
|
187
|
+
""",
|
|
188
|
+
tuple(cron_ids),
|
|
189
|
+
).fetchall()
|
|
190
|
+
except Exception:
|
|
191
|
+
return {}
|
|
192
|
+
finally:
|
|
193
|
+
with contextlib.suppress(Exception):
|
|
194
|
+
conn.close()
|
|
195
|
+
|
|
196
|
+
result: dict[str, datetime] = {}
|
|
197
|
+
for row in rows:
|
|
198
|
+
parsed = _parse_timestamp(row["started_at"], assume_utc=True)
|
|
199
|
+
if parsed is not None:
|
|
200
|
+
result[row["cron_id"]] = parsed
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def legacy_state_runs(*, state_file: Path = STATE_FILE) -> dict[str, datetime]:
|
|
205
|
+
state = _load_json(state_file, {})
|
|
206
|
+
if not isinstance(state, dict):
|
|
207
|
+
return {}
|
|
208
|
+
parsed: dict[str, datetime] = {}
|
|
209
|
+
for cron_id, value in state.items():
|
|
210
|
+
timestamp = _parse_timestamp(str(value), assume_utc=False)
|
|
211
|
+
if timestamp is not None:
|
|
212
|
+
parsed[str(cron_id)] = timestamp
|
|
213
|
+
return parsed
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def last_scheduled_time(calendar: dict, now: datetime | None = None) -> datetime:
|
|
217
|
+
now = now or datetime.now().astimezone(_local_timezone())
|
|
218
|
+
if now.tzinfo is None:
|
|
219
|
+
now = now.replace(tzinfo=_local_timezone())
|
|
220
|
+
|
|
221
|
+
hour = int(calendar.get("hour", calendar.get("Hour", 0)))
|
|
222
|
+
minute = int(calendar.get("minute", calendar.get("Minute", 0)))
|
|
223
|
+
weekday = calendar.get("weekday", calendar.get("Weekday"))
|
|
224
|
+
|
|
225
|
+
today_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
226
|
+
if weekday is not None:
|
|
227
|
+
py_weekday = (int(weekday) - 1) % 7
|
|
228
|
+
days_since = (now.weekday() - py_weekday) % 7
|
|
229
|
+
target = now - timedelta(days=days_since)
|
|
230
|
+
target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
231
|
+
if target > now:
|
|
232
|
+
target -= timedelta(weeks=1)
|
|
233
|
+
return target
|
|
234
|
+
if today_at <= now:
|
|
235
|
+
return today_at
|
|
236
|
+
return today_at - timedelta(days=1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def catchup_candidates(now: datetime | None = None) -> list[dict]:
|
|
240
|
+
now = now or datetime.now().astimezone(_local_timezone())
|
|
241
|
+
if now.tzinfo is None:
|
|
242
|
+
now = now.replace(tzinfo=_local_timezone())
|
|
243
|
+
|
|
244
|
+
crons = load_enabled_crons()
|
|
245
|
+
contracts = {cron["id"]: recovery_contract(cron) for cron in crons if cron.get("id")}
|
|
246
|
+
successes = latest_successful_runs(list(contracts), db_path=DB_PATH)
|
|
247
|
+
legacy = legacy_state_runs(state_file=STATE_FILE)
|
|
248
|
+
candidates: list[dict] = []
|
|
249
|
+
|
|
250
|
+
for cron in crons:
|
|
251
|
+
cron_id = cron.get("id")
|
|
252
|
+
if not cron_id or cron_id == "catchup":
|
|
253
|
+
continue
|
|
254
|
+
contract = contracts[cron_id]
|
|
255
|
+
schedule = effective_schedule(cron)
|
|
256
|
+
if contract["recovery_policy"] != "catchup":
|
|
257
|
+
continue
|
|
258
|
+
if schedule.get("schedule_type") != "calendar":
|
|
259
|
+
continue
|
|
260
|
+
if not contract["idempotent"]:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
due_at = last_scheduled_time(schedule["calendar"], now)
|
|
264
|
+
last_success = successes.get(cron_id) or legacy.get(cron_id)
|
|
265
|
+
age_seconds = max(int((now - due_at).total_seconds()), 0)
|
|
266
|
+
missed = last_success is None or last_success < due_at
|
|
267
|
+
within_window = contract["max_catchup_age"] <= 0 or age_seconds <= contract["max_catchup_age"]
|
|
268
|
+
|
|
269
|
+
candidates.append({
|
|
270
|
+
"cron_id": cron_id,
|
|
271
|
+
"script": cron.get("script", ""),
|
|
272
|
+
"type": cron.get("type", "python"),
|
|
273
|
+
"contract": contract,
|
|
274
|
+
"schedule": schedule,
|
|
275
|
+
"last_due_at": due_at,
|
|
276
|
+
"last_success_at": last_success,
|
|
277
|
+
"age_seconds": age_seconds,
|
|
278
|
+
"missed": missed,
|
|
279
|
+
"within_window": within_window,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
candidates.sort(key=lambda item: item["last_due_at"])
|
|
283
|
+
return candidates
|
package/src/crons/manifest.json
CHANGED
|
@@ -8,35 +8,60 @@
|
|
|
8
8
|
"type": "shell",
|
|
9
9
|
"schedule": {"hour": 4, "minute": 30},
|
|
10
10
|
"description": "Overnight session analysis — 4 phases: collect, extract, synthesize, apply",
|
|
11
|
-
"core": true
|
|
11
|
+
"core": true,
|
|
12
|
+
"recovery_policy": "catchup",
|
|
13
|
+
"idempotent": true,
|
|
14
|
+
"max_catchup_age": 172800,
|
|
15
|
+
"run_on_boot": true,
|
|
16
|
+
"run_on_wake": true
|
|
12
17
|
},
|
|
13
18
|
{
|
|
14
19
|
"id": "sleep",
|
|
15
20
|
"script": "scripts/nexo-sleep.py",
|
|
16
21
|
"schedule": {"hour": 4, "minute": 0},
|
|
17
22
|
"description": "Nightly memory consolidation and dream cycle",
|
|
18
|
-
"core": true
|
|
23
|
+
"core": true,
|
|
24
|
+
"recovery_policy": "catchup",
|
|
25
|
+
"idempotent": true,
|
|
26
|
+
"max_catchup_age": 172800,
|
|
27
|
+
"run_on_boot": true,
|
|
28
|
+
"run_on_wake": true
|
|
19
29
|
},
|
|
20
30
|
{
|
|
21
31
|
"id": "cognitive-decay",
|
|
22
32
|
"script": "scripts/nexo-cognitive-decay.py",
|
|
23
33
|
"schedule": {"hour": 3, "minute": 0},
|
|
24
34
|
"description": "Memory decay — reduce strength of unaccessed memories",
|
|
25
|
-
"core": true
|
|
35
|
+
"core": true,
|
|
36
|
+
"recovery_policy": "catchup",
|
|
37
|
+
"idempotent": true,
|
|
38
|
+
"max_catchup_age": 172800,
|
|
39
|
+
"run_on_boot": true,
|
|
40
|
+
"run_on_wake": true
|
|
26
41
|
},
|
|
27
42
|
{
|
|
28
43
|
"id": "learning-housekeep",
|
|
29
44
|
"script": "scripts/nexo-learning-housekeep.py",
|
|
30
45
|
"schedule": {"hour": 3, "minute": 15},
|
|
31
46
|
"description": "Archive stale learnings, deduplicate, validate",
|
|
32
|
-
"core": true
|
|
47
|
+
"core": true,
|
|
48
|
+
"recovery_policy": "catchup",
|
|
49
|
+
"idempotent": true,
|
|
50
|
+
"max_catchup_age": 172800,
|
|
51
|
+
"run_on_boot": true,
|
|
52
|
+
"run_on_wake": true
|
|
33
53
|
},
|
|
34
54
|
{
|
|
35
55
|
"id": "immune",
|
|
36
56
|
"script": "scripts/nexo-immune.py",
|
|
37
57
|
"interval_seconds": 1800,
|
|
38
58
|
"description": "Health monitor — checks MCP, DB, services, auto-repairs",
|
|
39
|
-
"core": true
|
|
59
|
+
"core": true,
|
|
60
|
+
"recovery_policy": "restart",
|
|
61
|
+
"idempotent": true,
|
|
62
|
+
"max_catchup_age": 7200,
|
|
63
|
+
"run_on_boot": true,
|
|
64
|
+
"run_on_wake": true
|
|
40
65
|
},
|
|
41
66
|
{
|
|
42
67
|
"id": "watchdog",
|
|
@@ -44,64 +69,97 @@
|
|
|
44
69
|
"type": "shell",
|
|
45
70
|
"interval_seconds": 1800,
|
|
46
71
|
"description": "System health checks — snapshots, logs, alerts",
|
|
47
|
-
"core": true
|
|
72
|
+
"core": true,
|
|
73
|
+
"recovery_policy": "restart",
|
|
74
|
+
"idempotent": true,
|
|
75
|
+
"max_catchup_age": 7200,
|
|
76
|
+
"run_on_boot": true,
|
|
77
|
+
"run_on_wake": true
|
|
48
78
|
},
|
|
49
79
|
{
|
|
50
80
|
"id": "self-audit",
|
|
51
81
|
"script": "scripts/nexo-daily-self-audit.py",
|
|
52
82
|
"schedule": {"hour": 7, "minute": 0},
|
|
53
83
|
"description": "Daily self-audit — validates learnings, protocols, drift",
|
|
54
|
-
"core": true
|
|
84
|
+
"core": true,
|
|
85
|
+
"recovery_policy": "catchup",
|
|
86
|
+
"idempotent": true,
|
|
87
|
+
"max_catchup_age": 172800,
|
|
88
|
+
"run_on_boot": true,
|
|
89
|
+
"run_on_wake": true
|
|
55
90
|
},
|
|
56
91
|
{
|
|
57
92
|
"id": "postmortem",
|
|
58
93
|
"script": "scripts/nexo-postmortem-consolidator.py",
|
|
59
94
|
"schedule": {"hour": 23, "minute": 30},
|
|
60
95
|
"description": "Consolidate session post-mortems into patterns",
|
|
61
|
-
"core": true
|
|
96
|
+
"core": true,
|
|
97
|
+
"recovery_policy": "catchup",
|
|
98
|
+
"idempotent": true,
|
|
99
|
+
"max_catchup_age": 172800,
|
|
100
|
+
"run_on_boot": true,
|
|
101
|
+
"run_on_wake": true
|
|
62
102
|
},
|
|
63
103
|
{
|
|
64
104
|
"id": "evolution",
|
|
65
105
|
"script": "scripts/nexo-evolution-run.py",
|
|
66
106
|
"schedule": {"hour": 5, "minute": 0, "weekday": 0},
|
|
67
107
|
"description": "Weekly self-improvement cycle — propose and evaluate changes",
|
|
68
|
-
"core": true
|
|
108
|
+
"core": true,
|
|
109
|
+
"recovery_policy": "catchup",
|
|
110
|
+
"idempotent": true,
|
|
111
|
+
"max_catchup_age": 1209600,
|
|
112
|
+
"run_on_boot": true,
|
|
113
|
+
"run_on_wake": true
|
|
69
114
|
},
|
|
70
115
|
{
|
|
71
116
|
"id": "followup-hygiene",
|
|
72
117
|
"script": "scripts/nexo-followup-hygiene.py",
|
|
73
118
|
"schedule": {"hour": 5, "minute": 0, "weekday": 0},
|
|
74
119
|
"description": "Clean stale followups, archive completed, validate dates",
|
|
75
|
-
"core": true
|
|
120
|
+
"core": true,
|
|
121
|
+
"recovery_policy": "catchup",
|
|
122
|
+
"idempotent": true,
|
|
123
|
+
"max_catchup_age": 1209600,
|
|
124
|
+
"run_on_boot": true,
|
|
125
|
+
"run_on_wake": true
|
|
76
126
|
},
|
|
77
127
|
{
|
|
78
128
|
"id": "synthesis",
|
|
79
129
|
"script": "scripts/nexo-synthesis.py",
|
|
80
130
|
"schedule": {"hour": 6, "minute": 0},
|
|
81
131
|
"description": "Daily synthesis — cross-reference learnings, decisions, changes",
|
|
82
|
-
"core": true
|
|
132
|
+
"core": true,
|
|
133
|
+
"recovery_policy": "catchup",
|
|
134
|
+
"idempotent": true,
|
|
135
|
+
"max_catchup_age": 172800,
|
|
136
|
+
"run_on_boot": true,
|
|
137
|
+
"run_on_wake": true
|
|
83
138
|
},
|
|
84
139
|
{
|
|
85
140
|
"id": "auto-close-sessions",
|
|
86
141
|
"script": "auto_close_sessions.py",
|
|
87
142
|
"interval_seconds": 300,
|
|
88
143
|
"description": "Close stale sessions that lost their parent process",
|
|
89
|
-
"core": true
|
|
144
|
+
"core": true,
|
|
145
|
+
"recovery_policy": "restart",
|
|
146
|
+
"idempotent": true,
|
|
147
|
+
"max_catchup_age": 1800,
|
|
148
|
+
"run_on_boot": true,
|
|
149
|
+
"run_on_wake": true
|
|
90
150
|
},
|
|
91
|
-
{
|
|
151
|
+
{
|
|
92
152
|
"id": "catchup",
|
|
93
153
|
"script": "scripts/nexo-catchup.py",
|
|
154
|
+
"interval_seconds": 900,
|
|
94
155
|
"run_at_load": true,
|
|
95
156
|
"description": "Morning catchup briefing for the user",
|
|
96
|
-
"core": true
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
"id": "day-orchestrator",
|
|
100
|
-
"script": "scripts/nexo-day-orchestrator.sh",
|
|
101
|
-
"type": "shell",
|
|
102
|
-
"description": "Autonomous NEXO cycle — checks followups, emails, infra every 15 min (8:00-23:00)",
|
|
103
157
|
"core": true,
|
|
104
|
-
"
|
|
158
|
+
"recovery_policy": "none",
|
|
159
|
+
"idempotent": true,
|
|
160
|
+
"max_catchup_age": 0,
|
|
161
|
+
"run_on_boot": true,
|
|
162
|
+
"run_on_wake": true
|
|
105
163
|
}
|
|
106
164
|
]
|
|
107
165
|
}
|