nexo-brain 3.0.1 → 3.1.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +82 -0
- package/community/launch/2026-04-v3-0-2/case-study-outreach.md +36 -0
- package/community/launch/2026-04-v3-0-2/devto-v3-0-2.md +91 -0
- package/community/launch/2026-04-v3-0-2/github-discussion-v3-0-2.md +58 -0
- package/community/launch/2026-04-v3-0-2/x-thread-v3-0-2.md +60 -0
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/src/auto_update.py +23 -6
- package/src/client_sync.py +6 -0
- package/src/cognitive/_memory.py +14 -7
- package/src/cognitive/_search.py +12 -5
- package/src/crons/sync.py +2 -1
- package/src/doctor/models.py +25 -0
- package/src/doctor/orchestrator.py +32 -2
- package/src/doctor/providers/boot.py +7 -7
- package/src/doctor/providers/deep.py +24 -21
- package/src/doctor/providers/runtime.py +154 -137
- package/src/evolution_cycle.py +76 -47
- package/src/hook_guardrails.py +182 -0
- package/src/hooks/protocol-guardrail.sh +1 -1
- package/src/hooks/protocol-pretool-guardrail.sh +9 -0
- package/src/kg_populate.py +21 -19
- package/src/maintenance.py +3 -3
- package/src/migrate_embeddings.py +36 -34
- package/src/plugins/backup.py +24 -12
- package/src/plugins/protocol.py +15 -0
- package/src/plugins/schedule.py +13 -1
- package/src/plugins/update.py +18 -4
- package/src/protocol_settings.py +59 -0
- package/src/public_contribution.py +10 -14
- package/src/public_evolution_queue.py +241 -0
- package/src/scripts/nexo-catchup.py +15 -15
- package/src/scripts/nexo-daily-self-audit.py +677 -28
- package/src/scripts/nexo-evolution-run.py +44 -4
- package/src/server.py +26 -1
- package/src/state_watchers_runtime.py +42 -35
package/src/plugins/schedule.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import platform
|
|
6
|
+
import re
|
|
6
7
|
import subprocess
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -69,9 +70,20 @@ def _summary_has_warning(summary: str = "") -> bool:
|
|
|
69
70
|
lowered = str(summary or "").strip().lower()
|
|
70
71
|
if not lowered:
|
|
71
72
|
return False
|
|
73
|
+
if re.search(r"\b[1-9]\d*\s+(errors?|warnings?)\b", lowered):
|
|
74
|
+
return True
|
|
72
75
|
if "no warning" in lowered or "without warnings" in lowered:
|
|
73
76
|
return False
|
|
74
|
-
warning_tokens = (
|
|
77
|
+
warning_tokens = (
|
|
78
|
+
"warning",
|
|
79
|
+
"warnings",
|
|
80
|
+
"warn:",
|
|
81
|
+
"degraded",
|
|
82
|
+
"partial failure",
|
|
83
|
+
"issues detected",
|
|
84
|
+
"completed with findings",
|
|
85
|
+
"findings detected",
|
|
86
|
+
)
|
|
75
87
|
return any(token in lowered for token in warning_tokens)
|
|
76
88
|
|
|
77
89
|
|
package/src/plugins/update.py
CHANGED
|
@@ -149,14 +149,21 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
149
149
|
|
|
150
150
|
for db_file in db_files:
|
|
151
151
|
dest = backup_dir / db_file.name
|
|
152
|
+
src_conn = None
|
|
153
|
+
dst_conn = None
|
|
152
154
|
try:
|
|
153
155
|
src_conn = sqlite3.connect(str(db_file))
|
|
154
156
|
dst_conn = sqlite3.connect(str(dest))
|
|
155
157
|
src_conn.backup(dst_conn)
|
|
156
|
-
dst_conn.close()
|
|
157
|
-
src_conn.close()
|
|
158
158
|
except Exception as e:
|
|
159
159
|
return str(backup_dir), f"Failed to backup {db_file.name}: {e}"
|
|
160
|
+
finally:
|
|
161
|
+
for conn in (dst_conn, src_conn):
|
|
162
|
+
if conn is not None:
|
|
163
|
+
try:
|
|
164
|
+
conn.close()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
160
167
|
|
|
161
168
|
return str(backup_dir), None
|
|
162
169
|
|
|
@@ -170,14 +177,21 @@ def _restore_databases(backup_dir: str):
|
|
|
170
177
|
# Try to find original location
|
|
171
178
|
for candidate in [DATA_DIR / db_backup.name, NEXO_HOME / db_backup.name, SRC_DIR / db_backup.name]:
|
|
172
179
|
if candidate.is_file():
|
|
180
|
+
src_conn = None
|
|
181
|
+
dst_conn = None
|
|
173
182
|
try:
|
|
174
183
|
src_conn = sqlite3.connect(str(db_backup))
|
|
175
184
|
dst_conn = sqlite3.connect(str(candidate))
|
|
176
185
|
src_conn.backup(dst_conn)
|
|
177
|
-
dst_conn.close()
|
|
178
|
-
src_conn.close()
|
|
179
186
|
except Exception:
|
|
180
187
|
pass
|
|
188
|
+
finally:
|
|
189
|
+
for conn in (dst_conn, src_conn):
|
|
190
|
+
if conn is not None:
|
|
191
|
+
try:
|
|
192
|
+
conn.close()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
181
195
|
break
|
|
182
196
|
|
|
183
197
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Shared protocol-discipline settings loaded from calibration.json."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_PROTOCOL_STRICTNESS = "lenient"
|
|
11
|
+
VALID_PROTOCOL_STRICTNESS = {"lenient", "strict", "learning"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _nexo_home() -> Path:
|
|
15
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _calibration_path() -> Path:
|
|
19
|
+
return _nexo_home() / "brain" / "calibration.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_protocol_strictness(value: str | None) -> str:
|
|
23
|
+
candidate = str(value or "").strip().lower()
|
|
24
|
+
aliases = {
|
|
25
|
+
"default": "lenient",
|
|
26
|
+
"normal": "lenient",
|
|
27
|
+
"off": "lenient",
|
|
28
|
+
"warn": "lenient",
|
|
29
|
+
"soft": "lenient",
|
|
30
|
+
"hard": "strict",
|
|
31
|
+
"guided": "learning",
|
|
32
|
+
}
|
|
33
|
+
candidate = aliases.get(candidate, candidate)
|
|
34
|
+
if candidate in VALID_PROTOCOL_STRICTNESS:
|
|
35
|
+
return candidate
|
|
36
|
+
return DEFAULT_PROTOCOL_STRICTNESS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_protocol_strictness() -> str:
|
|
40
|
+
env_override = os.environ.get("NEXO_PROTOCOL_STRICTNESS", "").strip()
|
|
41
|
+
if env_override:
|
|
42
|
+
return normalize_protocol_strictness(env_override)
|
|
43
|
+
|
|
44
|
+
cal_path = _calibration_path()
|
|
45
|
+
if not cal_path.is_file():
|
|
46
|
+
return DEFAULT_PROTOCOL_STRICTNESS
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(cal_path.read_text())
|
|
50
|
+
except Exception:
|
|
51
|
+
return DEFAULT_PROTOCOL_STRICTNESS
|
|
52
|
+
|
|
53
|
+
preferences = payload.get("preferences") if isinstance(payload, dict) else {}
|
|
54
|
+
candidate = ""
|
|
55
|
+
if isinstance(preferences, dict):
|
|
56
|
+
candidate = str(preferences.get("protocol_strictness", "") or "").strip()
|
|
57
|
+
if not candidate and isinstance(payload, dict):
|
|
58
|
+
candidate = str(payload.get("protocol_strictness", "") or "").strip()
|
|
59
|
+
return normalize_protocol_strictness(candidate)
|
|
@@ -14,7 +14,7 @@ import re
|
|
|
14
14
|
import shutil
|
|
15
15
|
import socket
|
|
16
16
|
import subprocess
|
|
17
|
-
from datetime import datetime,
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
20
|
from runtime_power import load_schedule_config, save_schedule_config
|
|
@@ -33,8 +33,6 @@ STATUS_PENDING_AUTH = "pending_auth"
|
|
|
33
33
|
STATUS_PAUSED_OPEN_PR = "paused_open_pr"
|
|
34
34
|
STATUS_COOLDOWN = "cooldown"
|
|
35
35
|
STATUS_OFF = "off"
|
|
36
|
-
COOLDOWN_HOURS_MERGED = 168
|
|
37
|
-
COOLDOWN_HOURS_CLOSED = 72
|
|
38
36
|
VALID_MODES = {MODE_UNSET, MODE_OFF, MODE_DRAFT_PRS, MODE_PENDING_AUTH}
|
|
39
37
|
VALID_STATUSES = {
|
|
40
38
|
STATUS_UNSET,
|
|
@@ -224,10 +222,6 @@ def _parse_iso(ts: str | None) -> datetime | None:
|
|
|
224
222
|
return None
|
|
225
223
|
|
|
226
224
|
|
|
227
|
-
def _future_iso(hours: int) -> str:
|
|
228
|
-
return (_utcnow() + timedelta(hours=hours)).isoformat()
|
|
229
|
-
|
|
230
|
-
|
|
231
225
|
def format_public_contribution_label(config: dict | None = None) -> str:
|
|
232
226
|
cfg = normalize_public_contribution_config(config)
|
|
233
227
|
if cfg["mode"] == MODE_DRAFT_PRS:
|
|
@@ -369,12 +363,13 @@ def refresh_public_contribution_state(config: dict | None = None) -> dict:
|
|
|
369
363
|
config["status"] = STATUS_PAUSED_OPEN_PR
|
|
370
364
|
save_public_contribution_config(config)
|
|
371
365
|
return config
|
|
366
|
+
resolution = "merged" if payload.get("mergedAt") else "closed"
|
|
372
367
|
config["active_pr_url"] = ""
|
|
373
368
|
config["active_pr_number"] = None
|
|
374
369
|
config["active_branch"] = ""
|
|
375
|
-
|
|
376
|
-
config["
|
|
377
|
-
config["
|
|
370
|
+
config["cooldown_until"] = ""
|
|
371
|
+
config["status"] = STATUS_ACTIVE
|
|
372
|
+
config["last_result"] = f"resolved_pr:{resolution}:{payload.get('url') or ''}".rstrip(":")
|
|
378
373
|
save_public_contribution_config(config)
|
|
379
374
|
return config
|
|
380
375
|
return _set_pending_auth(
|
|
@@ -384,7 +379,11 @@ def refresh_public_contribution_state(config: dict | None = None) -> dict:
|
|
|
384
379
|
|
|
385
380
|
cooldown_until = _parse_iso(config.get("cooldown_until"))
|
|
386
381
|
if cooldown_until and cooldown_until > _utcnow():
|
|
387
|
-
|
|
382
|
+
# Legacy installs used a post-merge/close cooldown that blocked the next
|
|
383
|
+
# public contribution cycle even after maintainers resolved the Draft PR.
|
|
384
|
+
# Public contribution should pause only while the PR is still open.
|
|
385
|
+
config["cooldown_until"] = ""
|
|
386
|
+
config["status"] = STATUS_ACTIVE
|
|
388
387
|
save_public_contribution_config(config)
|
|
389
388
|
return config
|
|
390
389
|
|
|
@@ -430,9 +429,6 @@ def can_run_public_contribution(config: dict | None = None) -> tuple[bool, str,
|
|
|
430
429
|
return False, "public contribution is disabled", config
|
|
431
430
|
if config["status"] == STATUS_PAUSED_OPEN_PR:
|
|
432
431
|
return False, "an active Draft PR is already open for this machine", config
|
|
433
|
-
cooldown_until = _parse_iso(config.get("cooldown_until"))
|
|
434
|
-
if cooldown_until and cooldown_until > _utcnow():
|
|
435
|
-
return False, f"cooldown until {cooldown_until.isoformat()}", config
|
|
436
432
|
return True, "", config
|
|
437
433
|
|
|
438
434
|
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Durable queue for public-core ports discovered outside the public runner.
|
|
4
|
+
|
|
5
|
+
Managed flows such as self-audit may apply a local/core fix inline. When that
|
|
6
|
+
fix belongs in the public repository as well, we persist a normalized queue
|
|
7
|
+
entry in ``evolution_log`` so the weekly public contribution cycle can port it
|
|
8
|
+
later instead of losing the improvement inside one machine.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
|
|
18
|
+
QUEUE_CLASSIFICATION = "public_port_queue"
|
|
19
|
+
QUEUE_STATUS_PENDING = "pending_public_port"
|
|
20
|
+
PUBLIC_ALLOWED_PREFIXES = (
|
|
21
|
+
"src/",
|
|
22
|
+
"bin/",
|
|
23
|
+
"tests/",
|
|
24
|
+
"templates/",
|
|
25
|
+
"hooks/",
|
|
26
|
+
"migrations/",
|
|
27
|
+
".claude-plugin/",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_repo_root(nexo_code: str | os.PathLike[str] | None = None) -> Path | None:
|
|
32
|
+
raw = Path(
|
|
33
|
+
nexo_code
|
|
34
|
+
or os.environ.get("NEXO_CODE")
|
|
35
|
+
or str(NEXO_HOME)
|
|
36
|
+
).expanduser()
|
|
37
|
+
candidates = []
|
|
38
|
+
if raw.name == "src":
|
|
39
|
+
candidates.append(raw.parent)
|
|
40
|
+
candidates.append(raw)
|
|
41
|
+
for candidate in candidates:
|
|
42
|
+
if (candidate / "package.json").exists():
|
|
43
|
+
return candidate.resolve()
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_public_path(
|
|
48
|
+
filepath: str,
|
|
49
|
+
*,
|
|
50
|
+
repo_root: Path | None = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
text = str(filepath or "").strip()
|
|
53
|
+
if not text:
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
normalized_raw = text.replace("\\", "/").lstrip("./")
|
|
57
|
+
if any(
|
|
58
|
+
normalized_raw == prefix.rstrip("/")
|
|
59
|
+
or normalized_raw.startswith(prefix)
|
|
60
|
+
for prefix in PUBLIC_ALLOWED_PREFIXES
|
|
61
|
+
):
|
|
62
|
+
return normalized_raw
|
|
63
|
+
|
|
64
|
+
repo_root = repo_root or resolve_repo_root()
|
|
65
|
+
if not repo_root:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
candidate = Path(text).expanduser()
|
|
69
|
+
if not candidate.is_absolute():
|
|
70
|
+
candidate = repo_root / candidate
|
|
71
|
+
try:
|
|
72
|
+
rel = candidate.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
73
|
+
except Exception:
|
|
74
|
+
for prefix in PUBLIC_ALLOWED_PREFIXES:
|
|
75
|
+
marker = normalized_raw.find(prefix)
|
|
76
|
+
if marker >= 0:
|
|
77
|
+
return normalized_raw[marker:]
|
|
78
|
+
return ""
|
|
79
|
+
if any(rel == prefix.rstrip("/") or rel.startswith(prefix) for prefix in PUBLIC_ALLOWED_PREFIXES):
|
|
80
|
+
return rel
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_public_core_path(filepath: str, *, repo_root: Path | None = None) -> bool:
|
|
85
|
+
return bool(normalize_public_path(filepath, repo_root=repo_root))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def queue_public_port_candidate(
|
|
89
|
+
conn: sqlite3.Connection,
|
|
90
|
+
*,
|
|
91
|
+
title: str,
|
|
92
|
+
reasoning: str,
|
|
93
|
+
files_changed: list[str],
|
|
94
|
+
source: str,
|
|
95
|
+
metadata: dict | None = None,
|
|
96
|
+
) -> dict:
|
|
97
|
+
row = conn.execute(
|
|
98
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='evolution_log'"
|
|
99
|
+
).fetchone()
|
|
100
|
+
if not row:
|
|
101
|
+
return {"ok": False, "reason": "evolution_log_missing"}
|
|
102
|
+
|
|
103
|
+
repo_root = resolve_repo_root()
|
|
104
|
+
normalized_files: list[str] = []
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
for filepath in files_changed:
|
|
107
|
+
rel = normalize_public_path(filepath, repo_root=repo_root)
|
|
108
|
+
if rel and rel not in seen:
|
|
109
|
+
normalized_files.append(rel)
|
|
110
|
+
seen.add(rel)
|
|
111
|
+
if not normalized_files:
|
|
112
|
+
return {"ok": False, "reason": "no_public_files"}
|
|
113
|
+
|
|
114
|
+
proposal = str(title or "").strip()[:300] or "Managed core autofix queued for public port"
|
|
115
|
+
clean_reasoning = str(reasoning or "").strip()[:4000] or "Queued for public-core port."
|
|
116
|
+
payload = dict(metadata or {})
|
|
117
|
+
payload.setdefault("source", source)
|
|
118
|
+
payload["files"] = normalized_files
|
|
119
|
+
|
|
120
|
+
existing = conn.execute(
|
|
121
|
+
"""SELECT id, status
|
|
122
|
+
FROM evolution_log
|
|
123
|
+
WHERE classification = ?
|
|
124
|
+
AND proposal = ?
|
|
125
|
+
AND files_changed = ?
|
|
126
|
+
AND status IN (?, 'draft_pr_created', 'skipped_duplicate_existing_pr')
|
|
127
|
+
ORDER BY id DESC
|
|
128
|
+
LIMIT 1""",
|
|
129
|
+
(
|
|
130
|
+
QUEUE_CLASSIFICATION,
|
|
131
|
+
proposal,
|
|
132
|
+
json.dumps(normalized_files),
|
|
133
|
+
QUEUE_STATUS_PENDING,
|
|
134
|
+
),
|
|
135
|
+
).fetchone()
|
|
136
|
+
if existing:
|
|
137
|
+
return {
|
|
138
|
+
"ok": True,
|
|
139
|
+
"queued": False,
|
|
140
|
+
"log_id": int(existing["id"]),
|
|
141
|
+
"status": str(existing["status"] or ""),
|
|
142
|
+
"files_changed": normalized_files,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
cur = conn.execute(
|
|
146
|
+
"""INSERT INTO evolution_log (
|
|
147
|
+
cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result
|
|
148
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
149
|
+
(
|
|
150
|
+
0,
|
|
151
|
+
"public_core",
|
|
152
|
+
proposal,
|
|
153
|
+
QUEUE_CLASSIFICATION,
|
|
154
|
+
clean_reasoning,
|
|
155
|
+
QUEUE_STATUS_PENDING,
|
|
156
|
+
json.dumps(normalized_files),
|
|
157
|
+
json.dumps(payload, ensure_ascii=False),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
return {
|
|
161
|
+
"ok": True,
|
|
162
|
+
"queued": True,
|
|
163
|
+
"log_id": int(cur.lastrowid),
|
|
164
|
+
"status": QUEUE_STATUS_PENDING,
|
|
165
|
+
"files_changed": normalized_files,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_pending_public_port_candidates(
|
|
170
|
+
conn: sqlite3.Connection,
|
|
171
|
+
*,
|
|
172
|
+
limit: int = 3,
|
|
173
|
+
) -> list[dict]:
|
|
174
|
+
rows = conn.execute(
|
|
175
|
+
"""SELECT id, created_at, proposal, reasoning, status, files_changed, test_result
|
|
176
|
+
FROM evolution_log
|
|
177
|
+
WHERE classification = ?
|
|
178
|
+
AND status = ?
|
|
179
|
+
ORDER BY created_at ASC, id ASC
|
|
180
|
+
LIMIT ?""",
|
|
181
|
+
(QUEUE_CLASSIFICATION, QUEUE_STATUS_PENDING, max(1, int(limit))),
|
|
182
|
+
).fetchall()
|
|
183
|
+
results: list[dict] = []
|
|
184
|
+
for row in rows:
|
|
185
|
+
metadata = {}
|
|
186
|
+
raw_payload = str(row["test_result"] or "").strip()
|
|
187
|
+
if raw_payload:
|
|
188
|
+
try:
|
|
189
|
+
parsed = json.loads(raw_payload)
|
|
190
|
+
if isinstance(parsed, dict):
|
|
191
|
+
metadata = parsed
|
|
192
|
+
except Exception:
|
|
193
|
+
metadata = {"raw": raw_payload}
|
|
194
|
+
files_changed = []
|
|
195
|
+
raw_files = str(row["files_changed"] or "").strip()
|
|
196
|
+
if raw_files:
|
|
197
|
+
try:
|
|
198
|
+
parsed_files = json.loads(raw_files)
|
|
199
|
+
if isinstance(parsed_files, list):
|
|
200
|
+
files_changed = [str(item).strip() for item in parsed_files if str(item).strip()]
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
results.append(
|
|
204
|
+
{
|
|
205
|
+
"id": int(row["id"]),
|
|
206
|
+
"created_at": str(row["created_at"] or ""),
|
|
207
|
+
"title": str(row["proposal"] or ""),
|
|
208
|
+
"reasoning": str(row["reasoning"] or ""),
|
|
209
|
+
"status": str(row["status"] or ""),
|
|
210
|
+
"files_changed": files_changed,
|
|
211
|
+
"metadata": metadata,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
return results
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def update_public_port_candidate(
|
|
218
|
+
conn: sqlite3.Connection,
|
|
219
|
+
log_id: int,
|
|
220
|
+
*,
|
|
221
|
+
status: str,
|
|
222
|
+
metadata_patch: dict | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
row = conn.execute(
|
|
225
|
+
"SELECT test_result FROM evolution_log WHERE id = ? LIMIT 1",
|
|
226
|
+
(int(log_id),),
|
|
227
|
+
).fetchone()
|
|
228
|
+
payload: dict = {}
|
|
229
|
+
if row and str(row["test_result"] or "").strip():
|
|
230
|
+
try:
|
|
231
|
+
parsed = json.loads(str(row["test_result"]))
|
|
232
|
+
if isinstance(parsed, dict):
|
|
233
|
+
payload = parsed
|
|
234
|
+
except Exception:
|
|
235
|
+
payload = {"raw": str(row["test_result"])}
|
|
236
|
+
if metadata_patch:
|
|
237
|
+
payload.update(metadata_patch)
|
|
238
|
+
conn.execute(
|
|
239
|
+
"UPDATE evolution_log SET status = ?, test_result = ? WHERE id = ?",
|
|
240
|
+
(status, json.dumps(payload, ensure_ascii=False), int(log_id)),
|
|
241
|
+
)
|
|
@@ -197,14 +197,14 @@ def main():
|
|
|
197
197
|
log("Catch-Up already running; skipping overlapping invocation.")
|
|
198
198
|
return
|
|
199
199
|
|
|
200
|
-
_heal_personal_schedules()
|
|
201
|
-
state = load_state()
|
|
202
|
-
tasks = catchup_candidates()
|
|
203
|
-
|
|
204
200
|
ran = 0
|
|
205
201
|
skipped = 0
|
|
206
202
|
skipped_out_of_window = 0
|
|
207
203
|
try:
|
|
204
|
+
_heal_personal_schedules()
|
|
205
|
+
state = load_state()
|
|
206
|
+
tasks = catchup_candidates()
|
|
207
|
+
|
|
208
208
|
for candidate in tasks:
|
|
209
209
|
name = candidate["cron_id"]
|
|
210
210
|
if not candidate.get("missed"):
|
|
@@ -221,19 +221,19 @@ def main():
|
|
|
221
221
|
log(f" {name} — missed scheduled run due at {due_at}, catching up...")
|
|
222
222
|
if run_task(candidate, state):
|
|
223
223
|
ran += 1
|
|
224
|
-
finally:
|
|
225
|
-
lock_handle.close()
|
|
226
224
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
225
|
+
if ran == 0 and skipped_out_of_window == 0:
|
|
226
|
+
log("All tasks up to date, nothing to catch up.")
|
|
227
|
+
elif ran >= 3:
|
|
228
|
+
# Many tasks caught up — ask CLI to assess system state
|
|
229
|
+
_cli_post_catchup_assessment(ran, skipped, state)
|
|
230
|
+
else:
|
|
231
|
+
suffix = f", {skipped_out_of_window} outside recovery window" if skipped_out_of_window else ""
|
|
232
|
+
log(f"Caught up {ran} tasks, {skipped} already current{suffix}.")
|
|
235
233
|
|
|
236
|
-
|
|
234
|
+
log("=== Catch-Up complete ===")
|
|
235
|
+
finally:
|
|
236
|
+
lock_handle.close()
|
|
237
237
|
|
|
238
238
|
|
|
239
239
|
def _cli_post_catchup_assessment(ran: int, skipped: int, state: dict):
|