nexo-brain 7.12.15 → 7.13.3
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 +3 -1
- package/package.json +1 -1
- package/src/auto_close_sessions.py +37 -1
- package/src/doctor/providers/runtime.py +6 -4
- package/src/hook_guardrails.py +57 -1
- package/src/hooks/post_edit_change_log.py +136 -0
- package/src/hooks/post_tool_use.py +16 -0
- package/src/plugins/update.py +19 -0
- package/src/runtime_versioning.py +63 -0
- package/src/script_registry.py +214 -1
- package/src/scripts/nexo-email-monitor.py +1 -1
- package/src/scripts/nexo-followup-runner.py +4 -3
- package/src/tree_hygiene.py +8 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.13.3",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.
|
|
21
|
+
Version `7.13.3` is the current packaged-runtime line. Unified release — doctor now repairs orphan personal script metadata and ignores historical `versions/**` snapshots, `nexo update` prunes runtime snapshots older than two back, protocol compliance self-heals missing task-open/change-log/stale-session gaps, headless automation uses bounded timeouts, Guardian false positives are tightened, and Codex CLI parity is release-gated. Result: coordinated Desktop bundles can ship the new Brain without changing the Mac/Windows installation contract.
|
|
22
|
+
|
|
23
|
+
Previously in `7.12.15`: patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
|
|
22
24
|
|
|
23
25
|
Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.13.3",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
@@ -152,6 +152,36 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
|
|
|
152
152
|
delete_diary_draft(sid)
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
|
|
156
|
+
"""Close stale open protocol tasks as partial when their session is reaped."""
|
|
157
|
+
rows = conn.execute(
|
|
158
|
+
"""SELECT task_id, goal
|
|
159
|
+
FROM protocol_tasks
|
|
160
|
+
WHERE session_id = ? AND status = 'open'
|
|
161
|
+
ORDER BY opened_at ASC""",
|
|
162
|
+
(sid,),
|
|
163
|
+
).fetchall()
|
|
164
|
+
closed: list[str] = []
|
|
165
|
+
for row in rows:
|
|
166
|
+
task_id = row["task_id"]
|
|
167
|
+
goal = str(row["goal"] or "")
|
|
168
|
+
evidence = (
|
|
169
|
+
f"Auto-closed as partial because session {sid} became stale before an explicit nexo_task_close. "
|
|
170
|
+
f"Session task: {task or 'unknown'}. Open goal: {goal[:240]}"
|
|
171
|
+
)
|
|
172
|
+
conn.execute(
|
|
173
|
+
"""UPDATE protocol_tasks
|
|
174
|
+
SET status = 'partial',
|
|
175
|
+
close_evidence = ?,
|
|
176
|
+
outcome_notes = 'auto-close: stale session ended without explicit task_close',
|
|
177
|
+
closed_at = datetime('now')
|
|
178
|
+
WHERE task_id = ? AND status = 'open'""",
|
|
179
|
+
(evidence[:4000], task_id),
|
|
180
|
+
)
|
|
181
|
+
closed.append(task_id)
|
|
182
|
+
return closed
|
|
183
|
+
|
|
184
|
+
|
|
155
185
|
def main():
|
|
156
186
|
init_db()
|
|
157
187
|
conn = get_db()
|
|
@@ -161,9 +191,12 @@ def main():
|
|
|
161
191
|
print(f"[{datetime.datetime.now().isoformat(timespec='seconds')}] No stale sessions")
|
|
162
192
|
return
|
|
163
193
|
|
|
194
|
+
closed_task_ids: list[str] = []
|
|
164
195
|
for session in orphans:
|
|
165
196
|
sid = session["sid"]
|
|
166
197
|
draft = get_diary_draft(sid)
|
|
198
|
+
closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
|
|
199
|
+
closed_task_ids.extend(closed_tasks)
|
|
167
200
|
|
|
168
201
|
if draft:
|
|
169
202
|
promote_draft_to_diary(sid, draft, task=session.get("task", ""))
|
|
@@ -196,7 +229,10 @@ def main():
|
|
|
196
229
|
os.makedirs(os.path.dirname(AUTO_CLOSE_LOG), exist_ok=True)
|
|
197
230
|
with open(AUTO_CLOSE_LOG, "a") as f:
|
|
198
231
|
ts = datetime.datetime.now().isoformat(timespec="seconds")
|
|
199
|
-
f.write(
|
|
232
|
+
f.write(
|
|
233
|
+
f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]} "
|
|
234
|
+
f"and {len(closed_task_ids)} protocol task(s): {closed_task_ids}\n"
|
|
235
|
+
)
|
|
200
236
|
|
|
201
237
|
|
|
202
238
|
if __name__ == "__main__":
|
|
@@ -704,10 +704,6 @@ def _client_assumption_regressions() -> list[str]:
|
|
|
704
704
|
}
|
|
705
705
|
offenders: list[str] = []
|
|
706
706
|
for path in src_root.rglob("*.py"):
|
|
707
|
-
try:
|
|
708
|
-
text = path.read_text()
|
|
709
|
-
except Exception:
|
|
710
|
-
continue
|
|
711
707
|
resolved = path.resolve()
|
|
712
708
|
try:
|
|
713
709
|
if resolved.is_relative_to(backup_root):
|
|
@@ -723,6 +719,12 @@ def _client_assumption_regressions() -> list[str]:
|
|
|
723
719
|
relative_path = resolved.relative_to(src_root)
|
|
724
720
|
except Exception:
|
|
725
721
|
relative_path = path.relative_to(src_root)
|
|
722
|
+
if "versions" in relative_path.parts:
|
|
723
|
+
continue
|
|
724
|
+
try:
|
|
725
|
+
text = path.read_text()
|
|
726
|
+
except Exception:
|
|
727
|
+
continue
|
|
726
728
|
if ".claude/projects" in text and relative_path not in allowed_relative_paths:
|
|
727
729
|
offenders.append(f"{relative_path} hardcodes ~/.claude/projects")
|
|
728
730
|
collect_path = src_root / "scripts" / "deep-sleep" / "collect.py"
|
package/src/hook_guardrails.py
CHANGED
|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
import paths
|
|
14
14
|
|
|
15
15
|
from core_prompts import render_core_prompt
|
|
16
|
-
from db import create_protocol_debt, get_db, get_last_heartbeat_ts
|
|
16
|
+
from db import create_protocol_debt, create_protocol_task, get_db, get_last_heartbeat_ts
|
|
17
17
|
from operator_language import append_operator_language_contract
|
|
18
18
|
from plugins.guard import _load_conditioned_learnings, _normalize_path_token
|
|
19
19
|
from protocol_settings import get_protocol_strictness
|
|
@@ -429,6 +429,42 @@ def _strict_write_without_task_severity(session_id: str) -> str:
|
|
|
429
429
|
return "error"
|
|
430
430
|
|
|
431
431
|
|
|
432
|
+
def _auto_task_open_enabled() -> bool:
|
|
433
|
+
return os.environ.get("NEXO_AUTO_TASK_OPEN", "1").strip().lower() not in {"0", "false", "no", "off"}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _auto_open_protocol_task_for_write(*, sid: str, tool_name: str, operation: str, files: list[str]) -> dict | None:
|
|
437
|
+
if not sid or not _auto_task_open_enabled():
|
|
438
|
+
return None
|
|
439
|
+
task_type = "edit" if operation == "write" else "execute"
|
|
440
|
+
clean_files = [item for item in files if str(item or "").strip()]
|
|
441
|
+
target = ", ".join(clean_files[:3]) if clean_files else "unknown target"
|
|
442
|
+
if len(clean_files) > 3:
|
|
443
|
+
target += f", +{len(clean_files) - 3} more"
|
|
444
|
+
try:
|
|
445
|
+
return create_protocol_task(
|
|
446
|
+
sid,
|
|
447
|
+
f"Auto-opened {task_type} task for {tool_name} on {target}",
|
|
448
|
+
task_type=task_type,
|
|
449
|
+
area="auto",
|
|
450
|
+
context_hint="PreToolUse auto-task_open: write/delete attempted without a matching open task.",
|
|
451
|
+
files=clean_files,
|
|
452
|
+
plan=[
|
|
453
|
+
"Auto-opened because the agent attempted a write/delete before explicit task_open.",
|
|
454
|
+
"Verify the edit and close with evidence.",
|
|
455
|
+
],
|
|
456
|
+
constraints=[
|
|
457
|
+
"Do not treat this auto-open as success evidence.",
|
|
458
|
+
"Close as done only after verification; otherwise close partial/failed.",
|
|
459
|
+
],
|
|
460
|
+
verification_step="Run the relevant test or inspection and close with evidence.",
|
|
461
|
+
must_verify=True,
|
|
462
|
+
must_change_log=True,
|
|
463
|
+
)
|
|
464
|
+
except Exception:
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
432
468
|
def _resolve_runtime_path(path: str) -> Path:
|
|
433
469
|
candidate = Path(str(path or "")).expanduser()
|
|
434
470
|
if not candidate.is_absolute():
|
|
@@ -1599,6 +1635,24 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1599
1635
|
"status": "blocked",
|
|
1600
1636
|
}
|
|
1601
1637
|
|
|
1638
|
+
auto_opened_task = None
|
|
1639
|
+
if files:
|
|
1640
|
+
missing_task_files = [filepath for filepath in files if not _find_open_task_for_file(conn, sid, filepath)]
|
|
1641
|
+
if missing_task_files:
|
|
1642
|
+
auto_opened_task = _auto_open_protocol_task_for_write(
|
|
1643
|
+
sid=sid,
|
|
1644
|
+
tool_name=tool_name,
|
|
1645
|
+
operation=op,
|
|
1646
|
+
files=missing_task_files,
|
|
1647
|
+
)
|
|
1648
|
+
elif not _find_any_open_task(conn, sid):
|
|
1649
|
+
auto_opened_task = _auto_open_protocol_task_for_write(
|
|
1650
|
+
sid=sid,
|
|
1651
|
+
tool_name=tool_name,
|
|
1652
|
+
operation=op,
|
|
1653
|
+
files=[],
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1602
1656
|
if not files:
|
|
1603
1657
|
task = _find_any_open_task(conn, sid)
|
|
1604
1658
|
if not task:
|
|
@@ -1628,6 +1682,7 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1628
1682
|
"operation": op,
|
|
1629
1683
|
"strictness": strictness,
|
|
1630
1684
|
"blocks": blocks,
|
|
1685
|
+
"auto_opened_task": auto_opened_task,
|
|
1631
1686
|
"status": "blocked" if blocks else "clean",
|
|
1632
1687
|
}
|
|
1633
1688
|
|
|
@@ -1711,6 +1766,7 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1711
1766
|
"operation": op,
|
|
1712
1767
|
"strictness": strictness,
|
|
1713
1768
|
"blocks": blocks,
|
|
1769
|
+
"auto_opened_task": auto_opened_task,
|
|
1714
1770
|
"status": "blocked" if blocks else "clean",
|
|
1715
1771
|
}
|
|
1716
1772
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
"""Best-effort PostToolUse change_log recorder for write tools.
|
|
5
|
+
|
|
6
|
+
This hook records file edit visibility directly in the DB layer. It never
|
|
7
|
+
calls MCP tools and never blocks the client pipeline.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_DIR = Path(__file__).resolve().parent
|
|
17
|
+
_SRC = _DIR.parent
|
|
18
|
+
if str(_SRC) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(_SRC))
|
|
20
|
+
|
|
21
|
+
WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _short_tool_name(tool_name: str) -> str:
|
|
25
|
+
clean = str(tool_name or "").strip()
|
|
26
|
+
return clean.rsplit("__", 1)[-1] if "__" in clean else clean
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_stdin_json() -> dict:
|
|
30
|
+
if sys.stdin.isatty():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
raw = sys.stdin.read()
|
|
34
|
+
if not raw.strip():
|
|
35
|
+
return {}
|
|
36
|
+
payload = json.loads(raw)
|
|
37
|
+
return payload if isinstance(payload, dict) else {}
|
|
38
|
+
except Exception:
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tool_input(payload: dict) -> dict:
|
|
43
|
+
value = payload.get("tool_input") or payload.get("toolInput") or payload.get("input") or {}
|
|
44
|
+
return value if isinstance(value, dict) else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_file_paths(payload: dict) -> list[str]:
|
|
48
|
+
tool_input = _tool_input(payload)
|
|
49
|
+
candidates: list[str] = []
|
|
50
|
+
for key in ("file_path", "filePath", "path", "notebook_path", "notebookPath"):
|
|
51
|
+
value = tool_input.get(key)
|
|
52
|
+
if isinstance(value, str) and value.strip():
|
|
53
|
+
candidates.append(value.strip())
|
|
54
|
+
for key in ("file_paths", "filePaths", "paths"):
|
|
55
|
+
value = tool_input.get(key)
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
candidates.extend(str(item).strip() for item in value if str(item).strip())
|
|
58
|
+
seen: set[str] = set()
|
|
59
|
+
paths: list[str] = []
|
|
60
|
+
for item in candidates:
|
|
61
|
+
if item in seen:
|
|
62
|
+
continue
|
|
63
|
+
seen.add(item)
|
|
64
|
+
paths.append(item)
|
|
65
|
+
return paths
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_sid_from_payload(payload: dict) -> str:
|
|
69
|
+
candidates: list[str] = []
|
|
70
|
+
for key in ("nexo_sid", "sid", "session_id", "sessionId"):
|
|
71
|
+
value = payload.get(key)
|
|
72
|
+
if isinstance(value, str) and value.strip():
|
|
73
|
+
candidates.append(value.strip())
|
|
74
|
+
for key in ("NEXO_SID", "CLAUDE_SESSION_ID"):
|
|
75
|
+
value = os.environ.get(key, "").strip()
|
|
76
|
+
if value:
|
|
77
|
+
candidates.append(value)
|
|
78
|
+
try:
|
|
79
|
+
from db import resolve_sid_from_external
|
|
80
|
+
except Exception:
|
|
81
|
+
return ""
|
|
82
|
+
for candidate in candidates:
|
|
83
|
+
if candidate.startswith("nexo-"):
|
|
84
|
+
return candidate
|
|
85
|
+
resolved = resolve_sid_from_external(candidate)
|
|
86
|
+
if resolved:
|
|
87
|
+
return resolved
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def record_post_edit_change(payload: dict) -> dict:
|
|
92
|
+
"""Record a minimal change_log row for write-like tool payloads."""
|
|
93
|
+
tool_name = _short_tool_name(str(payload.get("tool_name") or payload.get("toolName") or ""))
|
|
94
|
+
if tool_name not in WRITE_TOOLS:
|
|
95
|
+
return {"ok": True, "skipped": True, "reason": "tool_not_write"}
|
|
96
|
+
if os.environ.get("NEXO_AUTO_CHANGE_LOG", "1").strip().lower() in {"0", "false", "no", "off"}:
|
|
97
|
+
return {"ok": True, "skipped": True, "reason": "disabled"}
|
|
98
|
+
|
|
99
|
+
paths = _extract_file_paths(payload)
|
|
100
|
+
if not paths:
|
|
101
|
+
return {"ok": True, "skipped": True, "reason": "missing_file_path"}
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
from db import init_db, log_change
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
return {"ok": False, "error": f"db_import_failed: {exc}"}
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
init_db()
|
|
110
|
+
sid = _resolve_sid_from_payload(payload) or "unknown"
|
|
111
|
+
files = ", ".join(paths)
|
|
112
|
+
result = log_change(
|
|
113
|
+
sid,
|
|
114
|
+
files,
|
|
115
|
+
f"Auto-recorded PostToolUse {tool_name} file edit",
|
|
116
|
+
"PostToolUse observed a file write; recording traceability even if the agent forgets nexo_change_log.",
|
|
117
|
+
triggered_by="post_edit_change_log.py",
|
|
118
|
+
affects=files,
|
|
119
|
+
risks="Automatic trace only; verify the actual diff and tests separately.",
|
|
120
|
+
verify="Inspect git diff and run the relevant tests for the edited file.",
|
|
121
|
+
commit_ref="",
|
|
122
|
+
)
|
|
123
|
+
if "error" in result:
|
|
124
|
+
return {"ok": False, "error": result["error"]}
|
|
125
|
+
return {"ok": True, "change_log_id": result.get("id"), "files": paths}
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
return {"ok": False, "error": str(exc)}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> int:
|
|
131
|
+
record_post_edit_change(_read_stdin_json())
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
raise SystemExit(main())
|
|
@@ -208,6 +208,21 @@ def _run_auto_capture(payload: dict) -> int:
|
|
|
208
208
|
return 1
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
def _run_post_edit_change_log(payload: dict) -> int:
|
|
212
|
+
"""Record write-tool visibility without calling MCP from the hook."""
|
|
213
|
+
try:
|
|
214
|
+
proc = subprocess.run(
|
|
215
|
+
["python3", str(_DIR / "post_edit_change_log.py")],
|
|
216
|
+
input=json.dumps(payload),
|
|
217
|
+
capture_output=True,
|
|
218
|
+
text=True,
|
|
219
|
+
timeout=5,
|
|
220
|
+
)
|
|
221
|
+
return proc.returncode
|
|
222
|
+
except Exception:
|
|
223
|
+
return 1
|
|
224
|
+
|
|
225
|
+
|
|
211
226
|
def main() -> int:
|
|
212
227
|
started = time.time()
|
|
213
228
|
payload = _read_stdin_json()
|
|
@@ -231,6 +246,7 @@ def main() -> int:
|
|
|
231
246
|
protocol_message = stdout
|
|
232
247
|
|
|
233
248
|
exits.append(_run_auto_capture(payload))
|
|
249
|
+
exits.append(_run_post_edit_change_log(payload))
|
|
234
250
|
|
|
235
251
|
# v6.0.1 — inbox autodetect runs LAST so it sees the latest DB state
|
|
236
252
|
# (including any writes the previous steps may have done). Emits a
|
package/src/plugins/update.py
CHANGED
|
@@ -16,6 +16,7 @@ from runtime_versioning import (
|
|
|
16
16
|
activate_versioned_runtime_snapshot,
|
|
17
17
|
compute_mcp_runtime_fingerprint,
|
|
18
18
|
installed_force_restart_flag,
|
|
19
|
+
prune_old_versioned_runtime_snapshots,
|
|
19
20
|
write_restart_required_marker,
|
|
20
21
|
)
|
|
21
22
|
|
|
@@ -1236,6 +1237,7 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1236
1237
|
|
|
1237
1238
|
versioned_runtime_summary = None
|
|
1238
1239
|
restart_marker_summary = None
|
|
1240
|
+
version_prune_summary = None
|
|
1239
1241
|
mcp_code_changed = False
|
|
1240
1242
|
force_restart = False
|
|
1241
1243
|
new_fingerprint = ""
|
|
@@ -1246,6 +1248,10 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1246
1248
|
source_root=_runtime_code_root(),
|
|
1247
1249
|
version=new_version,
|
|
1248
1250
|
)
|
|
1251
|
+
version_prune_summary = prune_old_versioned_runtime_snapshots(
|
|
1252
|
+
keep=2,
|
|
1253
|
+
active_version=new_version,
|
|
1254
|
+
)
|
|
1249
1255
|
except Exception as e:
|
|
1250
1256
|
errors.append(f"versioned runtime activation: {e}")
|
|
1251
1257
|
# Decide whether the new release actually altered any MCP-loaded
|
|
@@ -1346,6 +1352,10 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1346
1352
|
lines.append(f" WARNING: launchagent reload: {launchagent_reload_warning}")
|
|
1347
1353
|
if versioned_runtime_summary and versioned_runtime_summary.get("ok"):
|
|
1348
1354
|
lines.append(f" Runtime activation: core/current -> versions/{new_version}")
|
|
1355
|
+
if version_prune_summary and version_prune_summary.get("pruned"):
|
|
1356
|
+
lines.append(
|
|
1357
|
+
f" Cleanup: pruned {len(version_prune_summary['pruned'])} old runtime version snapshot(s)"
|
|
1358
|
+
)
|
|
1349
1359
|
if restart_marker_summary:
|
|
1350
1360
|
lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
|
|
1351
1361
|
lines.append("")
|
|
@@ -1579,6 +1589,7 @@ def handle_update(
|
|
|
1579
1589
|
|
|
1580
1590
|
versioned_runtime_summary = None
|
|
1581
1591
|
restart_marker_summary = None
|
|
1592
|
+
version_prune_summary = None
|
|
1582
1593
|
mcp_code_changed = False
|
|
1583
1594
|
force_restart = False
|
|
1584
1595
|
new_fingerprint = ""
|
|
@@ -1589,6 +1600,10 @@ def handle_update(
|
|
|
1589
1600
|
source_root=SRC_DIR,
|
|
1590
1601
|
version=new_version,
|
|
1591
1602
|
)
|
|
1603
|
+
version_prune_summary = prune_old_versioned_runtime_snapshots(
|
|
1604
|
+
keep=2,
|
|
1605
|
+
active_version=new_version,
|
|
1606
|
+
)
|
|
1592
1607
|
steps_done.append("versioned-runtime")
|
|
1593
1608
|
except Exception as e:
|
|
1594
1609
|
raise RuntimeError(f"Versioned runtime activation failed: {e}")
|
|
@@ -1662,6 +1677,10 @@ def handle_update(
|
|
|
1662
1677
|
lines.append(" Clients: configured client targets synced")
|
|
1663
1678
|
if versioned_runtime_summary and versioned_runtime_summary.get("ok"):
|
|
1664
1679
|
lines.append(f" Runtime activation: core/current -> versions/{new_version}")
|
|
1680
|
+
if version_prune_summary and version_prune_summary.get("pruned"):
|
|
1681
|
+
lines.append(
|
|
1682
|
+
f" Cleanup: pruned {len(version_prune_summary['pruned'])} old runtime version snapshot(s)"
|
|
1683
|
+
)
|
|
1665
1684
|
if restart_marker_summary:
|
|
1666
1685
|
lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
|
|
1667
1686
|
lines.append("")
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
7
|
+
import re
|
|
6
8
|
import shutil
|
|
7
9
|
import time
|
|
8
10
|
from dataclasses import dataclass
|
|
@@ -539,6 +541,67 @@ def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, vers
|
|
|
539
541
|
}
|
|
540
542
|
|
|
541
543
|
|
|
544
|
+
def _runtime_version_sort_key(path: Path) -> tuple:
|
|
545
|
+
parts = re.split(r"([0-9]+)", path.name)
|
|
546
|
+
key = []
|
|
547
|
+
for part in parts:
|
|
548
|
+
if not part:
|
|
549
|
+
continue
|
|
550
|
+
if part.isdigit():
|
|
551
|
+
key.append((0, int(part)))
|
|
552
|
+
else:
|
|
553
|
+
key.append((1, part.lower()))
|
|
554
|
+
return tuple(key)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _active_version_name() -> str:
|
|
558
|
+
current = core_current_link()
|
|
559
|
+
if current.exists() or current.is_symlink():
|
|
560
|
+
with contextlib.suppress(Exception):
|
|
561
|
+
resolved = current.resolve(strict=False)
|
|
562
|
+
if resolved.parent.name == "versions":
|
|
563
|
+
return resolved.name
|
|
564
|
+
return installed_runtime_version()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def prune_old_versioned_runtime_snapshots(*, keep: int = 2, active_version: str = "") -> dict:
|
|
568
|
+
"""Remove runtime snapshots older than the active + newest ``keep`` versions."""
|
|
569
|
+
keep = max(int(keep or 0), 1)
|
|
570
|
+
versions_dir = core_versions_dir()
|
|
571
|
+
report = {
|
|
572
|
+
"ok": True,
|
|
573
|
+
"versions_dir": str(versions_dir),
|
|
574
|
+
"keep": keep,
|
|
575
|
+
"active_version": str(active_version or "").strip(),
|
|
576
|
+
"kept": [],
|
|
577
|
+
"pruned": [],
|
|
578
|
+
"errors": [],
|
|
579
|
+
}
|
|
580
|
+
if not versions_dir.is_dir():
|
|
581
|
+
return report
|
|
582
|
+
|
|
583
|
+
snapshots = [item for item in versions_dir.iterdir() if item.is_dir() and not item.is_symlink()]
|
|
584
|
+
snapshots.sort(key=_runtime_version_sort_key)
|
|
585
|
+
active = str(active_version or _active_version_name() or "").strip()
|
|
586
|
+
report["active_version"] = active
|
|
587
|
+
|
|
588
|
+
keep_names = {item.name for item in snapshots[-keep:]}
|
|
589
|
+
if active:
|
|
590
|
+
keep_names.add(active)
|
|
591
|
+
|
|
592
|
+
for snapshot in snapshots:
|
|
593
|
+
if snapshot.name in keep_names:
|
|
594
|
+
report["kept"].append(snapshot.name)
|
|
595
|
+
continue
|
|
596
|
+
try:
|
|
597
|
+
shutil.rmtree(snapshot)
|
|
598
|
+
report["pruned"].append(snapshot.name)
|
|
599
|
+
except Exception as exc:
|
|
600
|
+
report["ok"] = False
|
|
601
|
+
report["errors"].append({"version": snapshot.name, "error": str(exc)})
|
|
602
|
+
return report
|
|
603
|
+
|
|
604
|
+
|
|
542
605
|
def clear_restart_required_marker(
|
|
543
606
|
*,
|
|
544
607
|
client: str = "",
|
package/src/script_registry.py
CHANGED
|
@@ -95,6 +95,24 @@ SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
|
|
|
95
95
|
PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
|
|
96
96
|
SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
|
|
97
97
|
PERSONAL_SCRIPT_FILENAME_PREFIX = "ps-"
|
|
98
|
+
_RUNTIME_METADATA_ALIASES = {
|
|
99
|
+
"bash": "shell",
|
|
100
|
+
"sh": "shell",
|
|
101
|
+
"zsh": "shell",
|
|
102
|
+
"shellscript": "shell",
|
|
103
|
+
"python3": "python",
|
|
104
|
+
"py": "python",
|
|
105
|
+
"nodejs": "node",
|
|
106
|
+
"javascript": "node",
|
|
107
|
+
}
|
|
108
|
+
_SCHEDULE_WEEKDAY_SUFFIX_RE = re.compile(
|
|
109
|
+
r"^(?P<hour>\d{1,2}):(?P<minute>\d{2})\s+weekday\s*=\s*(?P<weekday>\d)$",
|
|
110
|
+
re.IGNORECASE,
|
|
111
|
+
)
|
|
112
|
+
_SCHEDULE_WEEKDAY_PREFIX_RE = re.compile(
|
|
113
|
+
r"^weekday\s*=\s*(?P<weekday>\d)\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})$",
|
|
114
|
+
re.IGNORECASE,
|
|
115
|
+
)
|
|
98
116
|
_LEGACY_CORE_SCRIPT_ALIASES = {
|
|
99
117
|
"nexo-postcompact.sh": "post-compact.sh",
|
|
100
118
|
"nexo-memory-precompact.sh": "pre-compact.sh",
|
|
@@ -341,10 +359,40 @@ def parse_inline_metadata(path: Path) -> dict:
|
|
|
341
359
|
key, value = payload.split("=", 1)
|
|
342
360
|
k = key.strip()
|
|
343
361
|
if k in METADATA_KEYS:
|
|
344
|
-
meta[k] = value.strip()
|
|
362
|
+
meta[k] = _normalize_metadata_value(k, value.strip())
|
|
345
363
|
return meta
|
|
346
364
|
|
|
347
365
|
|
|
366
|
+
def _normalize_metadata_value(key: str, value: str) -> str:
|
|
367
|
+
if key == "runtime":
|
|
368
|
+
return _normalize_runtime_metadata(value)
|
|
369
|
+
if key == "schedule":
|
|
370
|
+
return _normalize_schedule_metadata(value)
|
|
371
|
+
return value
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _normalize_runtime_metadata(value: str) -> str:
|
|
375
|
+
candidate = str(value or "").strip().lower()
|
|
376
|
+
return _RUNTIME_METADATA_ALIASES.get(candidate, candidate)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _normalize_schedule_metadata(value: str) -> str:
|
|
380
|
+
candidate = re.sub(r"\s+", " ", str(value or "").strip())
|
|
381
|
+
for pattern in (_SCHEDULE_WEEKDAY_SUFFIX_RE, _SCHEDULE_WEEKDAY_PREFIX_RE):
|
|
382
|
+
match = pattern.match(candidate)
|
|
383
|
+
if not match:
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
hour = int(match.group("hour"))
|
|
387
|
+
minute = int(match.group("minute"))
|
|
388
|
+
weekday = int(match.group("weekday"))
|
|
389
|
+
except ValueError:
|
|
390
|
+
return candidate
|
|
391
|
+
if 0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= weekday <= 6:
|
|
392
|
+
return f"{hour:02d}:{minute:02d}:{weekday}"
|
|
393
|
+
return candidate
|
|
394
|
+
|
|
395
|
+
|
|
348
396
|
def _detect_shebang(path: Path) -> str | None:
|
|
349
397
|
"""Read first line for shebang."""
|
|
350
398
|
try:
|
|
@@ -1068,6 +1116,169 @@ def _canonical_schedule_value(schedule_type: str, schedule_value: str | dict | l
|
|
|
1068
1116
|
return str(schedule_value or "")
|
|
1069
1117
|
|
|
1070
1118
|
|
|
1119
|
+
def _metadata_comment_prefix(path: Path) -> str:
|
|
1120
|
+
return "//" if path.suffix.lower() == ".js" else "#"
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _compact_schedule_from_record(record: dict) -> str:
|
|
1124
|
+
raw = str(record.get("schedule_value") or "").strip()
|
|
1125
|
+
if not raw:
|
|
1126
|
+
return ""
|
|
1127
|
+
payload = None
|
|
1128
|
+
if raw.lstrip().startswith("{") or raw.lstrip().startswith("["):
|
|
1129
|
+
with contextlib.suppress(Exception):
|
|
1130
|
+
payload = json.loads(raw)
|
|
1131
|
+
if isinstance(payload, list):
|
|
1132
|
+
payload = payload[0] if len(payload) == 1 and isinstance(payload[0], dict) else None
|
|
1133
|
+
if isinstance(payload, dict):
|
|
1134
|
+
hour = payload.get("Hour")
|
|
1135
|
+
minute = payload.get("Minute")
|
|
1136
|
+
weekday = payload.get("Weekday")
|
|
1137
|
+
try:
|
|
1138
|
+
hour_i = int(hour)
|
|
1139
|
+
minute_i = int(minute)
|
|
1140
|
+
weekday_i = int(weekday) if weekday is not None else None
|
|
1141
|
+
except (TypeError, ValueError):
|
|
1142
|
+
return ""
|
|
1143
|
+
if not (0 <= hour_i <= 23 and 0 <= minute_i <= 59):
|
|
1144
|
+
return ""
|
|
1145
|
+
if weekday_i is not None:
|
|
1146
|
+
if not (0 <= weekday_i <= 6):
|
|
1147
|
+
return ""
|
|
1148
|
+
return f"{hour_i:02d}:{minute_i:02d}:{weekday_i}"
|
|
1149
|
+
return f"{hour_i:02d}:{minute_i:02d}"
|
|
1150
|
+
|
|
1151
|
+
normalized = _normalize_schedule_metadata(raw)
|
|
1152
|
+
if _calendar_payload_from_declared(normalized) is not None:
|
|
1153
|
+
return normalized
|
|
1154
|
+
|
|
1155
|
+
label = str(record.get("schedule_label") or "").strip()
|
|
1156
|
+
match = re.search(r"(?P<hour>\d{1,2}):(?P<minute>\d{2})(?:\s+weekday=(?P<weekday>\d))?", label)
|
|
1157
|
+
if not match:
|
|
1158
|
+
return ""
|
|
1159
|
+
weekday_part = f":{match.group('weekday')}" if match.group("weekday") is not None else ""
|
|
1160
|
+
return f"{int(match.group('hour')):02d}:{int(match.group('minute')):02d}{weekday_part}"
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _inferred_schedule_metadata_lines(path: Path, metadata: dict, record: dict) -> list[str] | None:
|
|
1164
|
+
schedule_type = str(record.get("schedule_type") or "")
|
|
1165
|
+
if schedule_type not in {"interval", "calendar", "keep_alive"}:
|
|
1166
|
+
return None
|
|
1167
|
+
|
|
1168
|
+
name = _logical_personal_script_name(str(metadata.get("name") or path.stem))
|
|
1169
|
+
description = str(metadata.get("description") or "Personal automation managed by NEXO").strip()
|
|
1170
|
+
runtime = classify_runtime(path, metadata)
|
|
1171
|
+
if runtime == "unknown":
|
|
1172
|
+
runtime = "shell" if path.suffix.lower() == ".sh" else "python"
|
|
1173
|
+
cron_id = _safe_slug(str(record.get("cron_id") or metadata.get("cron_id") or name))
|
|
1174
|
+
prefix = _metadata_comment_prefix(path)
|
|
1175
|
+
|
|
1176
|
+
lines = [
|
|
1177
|
+
f"{prefix} nexo: name={name}",
|
|
1178
|
+
f"{prefix} nexo: description={description}",
|
|
1179
|
+
f"{prefix} nexo: runtime={runtime}",
|
|
1180
|
+
f"{prefix} nexo: cron_id={cron_id}",
|
|
1181
|
+
f"{prefix} nexo: schedule_required=true",
|
|
1182
|
+
]
|
|
1183
|
+
if schedule_type == "interval":
|
|
1184
|
+
try:
|
|
1185
|
+
interval = int(str(record.get("schedule_value") or "").strip())
|
|
1186
|
+
except ValueError:
|
|
1187
|
+
return None
|
|
1188
|
+
if interval <= 0:
|
|
1189
|
+
return None
|
|
1190
|
+
lines.append(f"{prefix} nexo: interval_seconds={interval}")
|
|
1191
|
+
lines.append(f"{prefix} nexo: recovery_policy=run_once_on_wake")
|
|
1192
|
+
elif schedule_type == "calendar":
|
|
1193
|
+
compact_schedule = _compact_schedule_from_record(record)
|
|
1194
|
+
if not compact_schedule:
|
|
1195
|
+
return None
|
|
1196
|
+
lines.append(f"{prefix} nexo: schedule={compact_schedule}")
|
|
1197
|
+
lines.append(f"{prefix} nexo: recovery_policy=catchup")
|
|
1198
|
+
elif schedule_type == "keep_alive":
|
|
1199
|
+
lines.append(f"{prefix} nexo: recovery_policy=restart_daemon")
|
|
1200
|
+
|
|
1201
|
+
if record.get("run_at_load"):
|
|
1202
|
+
lines.append(f"{prefix} nexo: run_on_boot=true")
|
|
1203
|
+
return lines
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _write_metadata_block(path: Path, metadata_lines: list[str]) -> None:
|
|
1207
|
+
raw = path.read_text(errors="ignore")
|
|
1208
|
+
lines = raw.splitlines(keepends=True)
|
|
1209
|
+
insert_at = 1 if lines and lines[0].startswith("#!") else 0
|
|
1210
|
+
filtered: list[str] = []
|
|
1211
|
+
for index, line in enumerate(lines):
|
|
1212
|
+
stripped = line.lstrip()
|
|
1213
|
+
if index >= insert_at and index < 25 and (
|
|
1214
|
+
stripped.startswith("# nexo:") or stripped.startswith("// nexo:")
|
|
1215
|
+
):
|
|
1216
|
+
continue
|
|
1217
|
+
filtered.append(line)
|
|
1218
|
+
block = [line.rstrip("\n") + "\n" for line in metadata_lines]
|
|
1219
|
+
filtered[insert_at:insert_at] = block
|
|
1220
|
+
path.write_text("".join(filtered))
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def repair_orphan_personal_schedule_metadata(*, dry_run: bool = False) -> dict:
|
|
1224
|
+
"""Infer inline metadata for personal LaunchAgents that predate the registry.
|
|
1225
|
+
|
|
1226
|
+
This powers ``nexo doctor --fix`` and ``nexo scripts reconcile`` for the
|
|
1227
|
+
legacy case where a user-owned LaunchAgent exists but the script has no
|
|
1228
|
+
declared schedule metadata. It never touches scripts outside the personal
|
|
1229
|
+
scripts directory.
|
|
1230
|
+
"""
|
|
1231
|
+
classification = classify_scripts_dir()
|
|
1232
|
+
personal_scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
|
|
1233
|
+
scripts_by_path = {
|
|
1234
|
+
str(Path(entry["path"]).expanduser().resolve(strict=False)): entry
|
|
1235
|
+
for entry in personal_scripts
|
|
1236
|
+
}
|
|
1237
|
+
report = {
|
|
1238
|
+
"ok": True,
|
|
1239
|
+
"dry_run": dry_run,
|
|
1240
|
+
"repaired": [],
|
|
1241
|
+
"skipped": [],
|
|
1242
|
+
"errors": [],
|
|
1243
|
+
}
|
|
1244
|
+
for record in _discover_personal_schedule_records():
|
|
1245
|
+
script_path = str(record.get("script_path") or "")
|
|
1246
|
+
if not script_path:
|
|
1247
|
+
report["skipped"].append({"cron_id": record.get("cron_id", ""), "reason": "missing script path"})
|
|
1248
|
+
continue
|
|
1249
|
+
resolved_path = str(Path(script_path).expanduser().resolve(strict=False))
|
|
1250
|
+
script = scripts_by_path.get(resolved_path)
|
|
1251
|
+
if not script:
|
|
1252
|
+
report["skipped"].append({"cron_id": record.get("cron_id", ""), "path": script_path, "reason": "not a registered personal script"})
|
|
1253
|
+
continue
|
|
1254
|
+
declared = script.get("declared_schedule", {})
|
|
1255
|
+
if declared.get("required") and declared.get("valid"):
|
|
1256
|
+
report["skipped"].append({"cron_id": record.get("cron_id", ""), "path": script_path, "reason": "already has valid schedule metadata"})
|
|
1257
|
+
continue
|
|
1258
|
+
path = Path(script["path"])
|
|
1259
|
+
metadata_lines = _inferred_schedule_metadata_lines(path, script.get("metadata", {}), record)
|
|
1260
|
+
if not metadata_lines:
|
|
1261
|
+
report["skipped"].append({"cron_id": record.get("cron_id", ""), "path": script_path, "reason": "unsupported schedule metadata inference"})
|
|
1262
|
+
continue
|
|
1263
|
+
entry = {
|
|
1264
|
+
"cron_id": str(record.get("cron_id") or ""),
|
|
1265
|
+
"path": str(path),
|
|
1266
|
+
"schedule_type": str(record.get("schedule_type") or ""),
|
|
1267
|
+
}
|
|
1268
|
+
if dry_run:
|
|
1269
|
+
entry["dry_run"] = True
|
|
1270
|
+
report["repaired"].append(entry)
|
|
1271
|
+
continue
|
|
1272
|
+
try:
|
|
1273
|
+
_write_metadata_block(path, metadata_lines)
|
|
1274
|
+
report["repaired"].append(entry)
|
|
1275
|
+
except Exception as exc:
|
|
1276
|
+
report["errors"].append({"path": str(path), "error": str(exc)})
|
|
1277
|
+
if report["errors"]:
|
|
1278
|
+
report["ok"] = False
|
|
1279
|
+
return report
|
|
1280
|
+
|
|
1281
|
+
|
|
1071
1282
|
def _extract_launchctl_value(output: str, prefixes: str | tuple[str, ...]) -> str | None:
|
|
1072
1283
|
if isinstance(prefixes, str):
|
|
1073
1284
|
prefixes = (prefixes,)
|
|
@@ -1605,12 +1816,14 @@ def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
|
|
|
1605
1816
|
def reconcile_personal_scripts(*, dry_run: bool = False) -> dict:
|
|
1606
1817
|
"""Full lifecycle reconciliation: classify, sync registry, ensure declared schedules."""
|
|
1607
1818
|
renamed_result = rename_legacy_personal_script_filenames(dry_run=dry_run)
|
|
1819
|
+
orphan_metadata_result = repair_orphan_personal_schedule_metadata(dry_run=dry_run)
|
|
1608
1820
|
sync_result = sync_personal_scripts()
|
|
1609
1821
|
ensure_result = ensure_personal_schedules(dry_run=dry_run)
|
|
1610
1822
|
return {
|
|
1611
1823
|
"ok": True,
|
|
1612
1824
|
"dry_run": dry_run,
|
|
1613
1825
|
"renamed_legacy_filenames": renamed_result,
|
|
1826
|
+
"repaired_orphan_schedule_metadata": orphan_metadata_result,
|
|
1614
1827
|
"sync": sync_result,
|
|
1615
1828
|
"marker_warnings": sync_result.get("marker_warnings", []),
|
|
1616
1829
|
"ensure_schedules": ensure_result,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# nexo: description=Continuous NEXO pending-work runner. Executes due followups, avoids overlap, and escalates operator attention through reminders/orchestrator.
|
|
4
4
|
# nexo: category=automation
|
|
5
5
|
# nexo: runtime=python
|
|
6
|
-
# nexo: timeout=
|
|
6
|
+
# nexo: timeout=10800
|
|
7
7
|
# nexo: cron_id=followup-runner
|
|
8
8
|
# nexo: interval_seconds=3600
|
|
9
9
|
# nexo: schedule_required=true
|
|
@@ -20,7 +20,7 @@ NEXO Followup Runner v8 — continuous pending-work runner.
|
|
|
20
20
|
Role:
|
|
21
21
|
1. Pick up due or recurring followups that should already be running.
|
|
22
22
|
2. Process them through the real NEXO runtime and its MCP surface.
|
|
23
|
-
3. Avoid overlap via lock +
|
|
23
|
+
3. Avoid overlap via lock + bounded timeout.
|
|
24
24
|
4. Escalate operator attention through standard NEXO reminders when needed.
|
|
25
25
|
|
|
26
26
|
From the operator's point of view, these are all "pending items". Internally,
|
|
@@ -58,6 +58,7 @@ from automation_controls import (
|
|
|
58
58
|
get_send_reply_script_path,
|
|
59
59
|
)
|
|
60
60
|
from client_preferences import resolve_automation_backend, resolve_client_runtime_profile
|
|
61
|
+
from constants import AUTOMATION_SUBPROCESS_TIMEOUT
|
|
61
62
|
from core_prompts import render_core_prompt
|
|
62
63
|
from operator_language import build_operator_language_contract, normalize_operator_language
|
|
63
64
|
import db as nexo_db
|
|
@@ -69,7 +70,7 @@ LOG_FILE = LOG_DIR / "followup-runner.log"
|
|
|
69
70
|
STATE_FILE = data_dir() / "followup-state.json"
|
|
70
71
|
RESULTS_FILE = data_dir() / "followup-runner-results.json"
|
|
71
72
|
|
|
72
|
-
CLI_TIMEOUT =
|
|
73
|
+
CLI_TIMEOUT = AUTOMATION_SUBPROCESS_TIMEOUT
|
|
73
74
|
LOCK_FILE = LOG_DIR / "followup-runner.lock"
|
|
74
75
|
MAX_FOLLOWUPS_PER_RUN = 5 # Focus: Opus can actually execute 5, not 30
|
|
75
76
|
COOLDOWN_DAYS = 3 # Don't retry needs_decision/blocked for 3 days
|
package/src/tree_hygiene.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
"""Shared tree hygiene helpers for runtime/install/release flows."""
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
+
import os
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
|
|
@@ -46,11 +47,11 @@ def find_duplicate_artifact_paths(root: str | Path) -> list[Path]:
|
|
|
46
47
|
"""Find duplicate copy artifacts under a tree, skipping generated/vendor directories."""
|
|
47
48
|
root_path = Path(root).resolve()
|
|
48
49
|
duplicates: list[Path] = []
|
|
49
|
-
for
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
for dirpath, dirnames, filenames in os.walk(root_path):
|
|
51
|
+
dirnames[:] = sorted(name for name in dirnames if name not in _IGNORED_DIRS)
|
|
52
|
+
current = Path(dirpath)
|
|
53
|
+
for filename in sorted(filenames):
|
|
54
|
+
path = current / filename
|
|
55
|
+
if is_duplicate_artifact_name(path):
|
|
56
|
+
duplicates.append(path)
|
|
56
57
|
return duplicates
|