nexo-brain 7.16.0 → 7.17.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 +5 -1
- package/package.json +1 -1
- package/src/agent_runner.py +79 -11
- package/src/plugins/guard.py +37 -13
- package/src/scripts/nexo-email-monitor.py +82 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.17.0",
|
|
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,11 @@
|
|
|
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.17.0` is the current packaged-runtime line. Minor release over v7.16.3 - the headless runner pre-emptive guard becomes advisory: it surfaces learnings/schemas to the agent and logs to `guard_checks`, but never returns `blocked=True`. The PreToolUse hook is the authoritative gate at write time. This closes the family of bugs where heuristic path matches in the prompt aborted email-monitor sessions, followup-runner cycles, Deep Sleep synth, and postmortem-consolidation. Also rolls in the directory-path hardening planned for 7.16.4.
|
|
22
|
+
|
|
23
|
+
Previously in `7.16.3`: patch release over v7.16.2 - the headless runner guard opts out of the runtime-core blocking rule because actual writes on those paths are already blocked at the PreToolUse layer.
|
|
24
|
+
|
|
25
|
+
Previously in `7.16.0`: minor release over v7.15.2 - Brain adds Memory Observations v2: evidence-backed event capture, derived observations, update-safe backfill, MCP retrieval, dashboard visibility, and safer refusal when memory lacks evidence.
|
|
22
26
|
|
|
23
27
|
Previously in `7.15.2`: patch release over v7.15.1 - Brain treats normal Codex startup context reads of calibration and project atlas files as healthy bootstrap activity instead of conditioned-file drift.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.17.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — 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",
|
package/src/agent_runner.py
CHANGED
|
@@ -405,15 +405,53 @@ def _runner_mutating_tools_allowed(allowed_tools: str) -> bool:
|
|
|
405
405
|
return bool(parts & _MUTATING_TOOL_NAMES)
|
|
406
406
|
|
|
407
407
|
|
|
408
|
+
_RUNNER_GUARD_EXEC_INVOCATION_PATTERN = re.compile(
|
|
409
|
+
r"\b(?:python3?|python|node|nodejs|bash|sh|zsh|ruby|deno|perl|pwsh|powershell|"
|
|
410
|
+
r"npx|pnpm|yarn|uv|pipx|env)\b\s+"
|
|
411
|
+
r"(/[^\s'\"`<>]+|[A-Za-z]:\\[^\s'\"`<>]+)"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
408
415
|
def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
|
|
416
|
+
"""Return paths that the prompt seems to instruct the agent to *edit*.
|
|
417
|
+
|
|
418
|
+
Paths that appear immediately after a known interpreter
|
|
419
|
+
(``python3 /path/to/tool.py ...``) are stripped out: those are
|
|
420
|
+
subprocess executions, not edits, and the runner guard must not treat
|
|
421
|
+
them as protected-file writes. Without this exclusion the email-monitor
|
|
422
|
+
prompt — which explicitly instructs the agent to invoke
|
|
423
|
+
``~/.nexo/core/scripts/nexo-send-reply.py`` to deliver the reply —
|
|
424
|
+
triggers the ``runtime-core`` blocking rule on every email and the
|
|
425
|
+
session aborts with exit 2 before any reply is generated.
|
|
426
|
+
"""
|
|
409
427
|
found: set[str] = set()
|
|
410
428
|
text = str(prompt or "")
|
|
429
|
+
|
|
430
|
+
exec_only_paths: set[str] = set()
|
|
431
|
+
for match in _RUNNER_GUARD_EXEC_INVOCATION_PATTERN.finditer(text):
|
|
432
|
+
candidate = match.group(1).rstrip(".,);:]")
|
|
433
|
+
if candidate:
|
|
434
|
+
exec_only_paths.add(candidate)
|
|
435
|
+
try:
|
|
436
|
+
exec_only_paths.add(str(Path(candidate).expanduser().resolve(strict=False)))
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
|
|
411
440
|
for match in re.findall(r"(?<![A-Za-z0-9_])(?:/[^\s'\"`<>]+|[A-Za-z]:\\[^\s'\"`<>]+)", text):
|
|
412
441
|
cleaned = match.rstrip(".,);:]")
|
|
413
|
-
|
|
414
|
-
|
|
442
|
+
# Drop trailing slashes — `/tmp/` and friends are directories, not
|
|
443
|
+
# edit targets. Without this the followup-runner prompt's mention
|
|
444
|
+
# of `/tmp/` reached `handle_guard_check`, which tried to `open()`
|
|
445
|
+
# the directory and crashed the whole pre-emptive guard.
|
|
446
|
+
cleaned = cleaned.rstrip("/")
|
|
447
|
+
if not cleaned or cleaned in exec_only_paths:
|
|
448
|
+
continue
|
|
449
|
+
found.add(cleaned)
|
|
415
450
|
for match in re.findall(r"(?<![A-Za-z0-9_])(?:src|scripts|tests|docs|lib|renderer|app)/[A-Za-z0-9_./-]+\.[A-Za-z0-9]+", text):
|
|
416
|
-
|
|
451
|
+
resolved = str((cwd / match.rstrip(".,);:]")).resolve())
|
|
452
|
+
if resolved in exec_only_paths:
|
|
453
|
+
continue
|
|
454
|
+
found.add(resolved)
|
|
417
455
|
try:
|
|
418
456
|
resolved_cwd = cwd.resolve()
|
|
419
457
|
except Exception:
|
|
@@ -428,34 +466,64 @@ def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
|
|
|
428
466
|
|
|
429
467
|
|
|
430
468
|
def _run_headless_runner_guard(*, caller: str, cwd: Path, prompt: str, allowed_tools: str) -> dict:
|
|
469
|
+
"""Pre-emptive runner guard — observational, never blocking.
|
|
470
|
+
|
|
471
|
+
The pre-emptive guard scans the *prompt text* for paths the agent might
|
|
472
|
+
edit. That is heuristic: it cannot tell whether a path will actually be
|
|
473
|
+
written, read, executed, or just mentioned in passing. Treating the
|
|
474
|
+
learnings/blocking-rules surfaced by that heuristic as hard blockers
|
|
475
|
+
has caused a year's worth of operational pain — every time a learning's
|
|
476
|
+
``applies_to`` happens to match a path the prompt mentions for any
|
|
477
|
+
reason, every cron, every email-monitor session, every Deep Sleep
|
|
478
|
+
synth aborts with exit 2.
|
|
479
|
+
|
|
480
|
+
The authoritative gate is the PreToolUse hook
|
|
481
|
+
(``hook_guardrails._collect_runtime_core_write_blocks`` and the
|
|
482
|
+
learning-aware blocks alongside it). That layer fires only when the
|
|
483
|
+
agent *actually attempts* a Write/Edit, with severity ``error``, and
|
|
484
|
+
prevents the protected mutation. The pre-emptive guard's job is to
|
|
485
|
+
surface relevant learnings/schemas to the agent up front so it can
|
|
486
|
+
reason about them, plus log to ``guard_checks`` for observability —
|
|
487
|
+
not to abort the run on a regex match.
|
|
488
|
+
|
|
489
|
+
Therefore this function always returns ``blocked=False``. Errors from
|
|
490
|
+
``handle_guard_check`` are also non-blocking; we let the run start so
|
|
491
|
+
the PreToolUse layer can do its job, and we log the unavailability for
|
|
492
|
+
diagnostics.
|
|
493
|
+
"""
|
|
431
494
|
if not _runner_mutating_tools_allowed(allowed_tools):
|
|
432
495
|
return {"blocked": False, "skipped": "read_only_tools"}
|
|
433
496
|
guard_paths = _extract_runner_guard_paths(prompt, cwd)
|
|
434
497
|
if not guard_paths:
|
|
435
498
|
return {"blocked": False, "skipped": "no_explicit_paths"}
|
|
499
|
+
summary = ""
|
|
436
500
|
try:
|
|
437
501
|
runtime_root = str(NEXO_HOME)
|
|
438
502
|
if runtime_root and runtime_root not in sys.path:
|
|
439
503
|
sys.path.insert(0, runtime_root)
|
|
440
504
|
from plugins.guard import handle_guard_check # type: ignore
|
|
441
505
|
|
|
506
|
+
# We still pass ``enforce_runtime_core_block="false"`` because that
|
|
507
|
+
# opt-out predates the advisory-only switch and other callers may
|
|
508
|
+
# still rely on it. With the advisory contract in place this flag
|
|
509
|
+
# is effectively redundant, but kept for back-compat.
|
|
442
510
|
output = handle_guard_check(
|
|
443
511
|
files=",".join(guard_paths),
|
|
444
512
|
area=f"runner:{caller or 'headless'}",
|
|
445
513
|
project_hint=f"headless runner caller={caller or 'unknown'} cwd={cwd}",
|
|
446
514
|
include_schemas="true",
|
|
515
|
+
enforce_runtime_core_block="false",
|
|
447
516
|
)
|
|
517
|
+
summary = str(output or "")
|
|
448
518
|
except Exception as exc:
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
"paths": guard_paths,
|
|
453
|
-
}
|
|
454
|
-
blocked = "BLOCKING RULES" in str(output or "")
|
|
519
|
+
# Guard unavailable is observational, not blocking. The PreToolUse
|
|
520
|
+
# hook will catch any actual protected write at execute time.
|
|
521
|
+
summary = f"Runner guard unavailable: {exc}"
|
|
455
522
|
return {
|
|
456
|
-
"blocked":
|
|
457
|
-
"summary":
|
|
523
|
+
"blocked": False,
|
|
524
|
+
"summary": summary,
|
|
458
525
|
"paths": guard_paths,
|
|
526
|
+
"advisory": True,
|
|
459
527
|
}
|
|
460
528
|
|
|
461
529
|
|
package/src/plugins/guard.py
CHANGED
|
@@ -309,6 +309,7 @@ def handle_guard_check(
|
|
|
309
309
|
area: str = "",
|
|
310
310
|
project_hint: str = "",
|
|
311
311
|
include_schemas: str = "true",
|
|
312
|
+
enforce_runtime_core_block: str = "true",
|
|
312
313
|
) -> str:
|
|
313
314
|
"""Check learnings relevant to files/area before editing. Call BEFORE any code change.
|
|
314
315
|
|
|
@@ -316,11 +317,21 @@ def handle_guard_check(
|
|
|
316
317
|
files: Comma-separated file paths about to be edited
|
|
317
318
|
area: System area (webapp, shopify, infrastructure, nexo-ops, etc.)
|
|
318
319
|
include_schemas: Include DB table schemas if files touch database code (true/false)
|
|
320
|
+
enforce_runtime_core_block: When ``true`` (default) any path under
|
|
321
|
+
``~/.nexo/core/`` adds a ``runtime-core`` blocking rule. When
|
|
322
|
+
``false`` the caller is opting out because another guard layer
|
|
323
|
+
already protects those writes (the PreToolUse hook in
|
|
324
|
+
``hook_guardrails._collect_runtime_core_write_blocks`` blocks any
|
|
325
|
+
actual Write/Edit on core paths with severity ``error``).
|
|
326
|
+
``_run_headless_runner_guard`` passes ``false`` so that paths
|
|
327
|
+
mentioned in a prompt — e.g. invocations of helper scripts —
|
|
328
|
+
no longer abort the session pre-emptively.
|
|
319
329
|
"""
|
|
320
330
|
conn = get_db()
|
|
321
331
|
include_schemas_bool = include_schemas.lower() in ("true", "1", "yes")
|
|
322
332
|
file_list = [f.strip() for f in files.split(",") if f.strip()] if files else []
|
|
323
333
|
project_hint_active = bool(_project_hint_tokens(project_hint))
|
|
334
|
+
enforce_runtime_core_bool = str(enforce_runtime_core_block or "").lower() in ("true", "1", "yes")
|
|
324
335
|
|
|
325
336
|
result = {
|
|
326
337
|
"learnings": [],
|
|
@@ -335,18 +346,19 @@ def handle_guard_check(
|
|
|
335
346
|
conditioned_blocking_seen = set()
|
|
336
347
|
conditioned_by_file = _load_conditioned_learnings(conn, file_list) if file_list else {}
|
|
337
348
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
"
|
|
342
|
-
|
|
343
|
-
"
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
349
|
+
if enforce_runtime_core_bool:
|
|
350
|
+
runtime_core_hits = [filepath for filepath in file_list if _is_runtime_core_path(filepath)]
|
|
351
|
+
for filepath in runtime_core_hits:
|
|
352
|
+
result["blocking_rules"].append({
|
|
353
|
+
"id": "runtime-core",
|
|
354
|
+
"rule": (
|
|
355
|
+
"Installed runtime core files are protected. Edit the source repo, validate there, "
|
|
356
|
+
"then ship the change through release/update instead of mutating ~/.nexo/core directly."
|
|
357
|
+
),
|
|
358
|
+
"repetitions": 0,
|
|
359
|
+
"reason": "runtime_core_protected",
|
|
360
|
+
"file": filepath,
|
|
361
|
+
})
|
|
350
362
|
|
|
351
363
|
# 1. File-conditioned learnings — explicit applies_to guardrails for target files
|
|
352
364
|
hit_ids = []
|
|
@@ -482,12 +494,24 @@ def handle_guard_check(
|
|
|
482
494
|
all_tables = set()
|
|
483
495
|
for filepath in file_list:
|
|
484
496
|
try:
|
|
497
|
+
# Skip directories silently — the path extractor that calls
|
|
498
|
+
# us is permissive and can hand back paths that end in `/`
|
|
499
|
+
# (e.g. `/tmp/`) or that resolve to real directories. Opening
|
|
500
|
+
# one of those raises IsADirectoryError, which is an OSError
|
|
501
|
+
# subclass; the prior except only caught FileNotFoundError /
|
|
502
|
+
# PermissionError, so the exception propagated and the whole
|
|
503
|
+
# runner pre-emptive guard returned `blocked=True` with
|
|
504
|
+
# "Runner guard unavailable: [Errno 21] Is a directory".
|
|
505
|
+
# That is exactly what was killing the followup-runner every
|
|
506
|
+
# hour after 7.16.0.
|
|
507
|
+
if Path(filepath).is_dir():
|
|
508
|
+
continue
|
|
485
509
|
with open(filepath, 'r', errors='ignore') as f:
|
|
486
510
|
content = f.read()
|
|
487
511
|
sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE TABLE']
|
|
488
512
|
if any(kw in content.upper() for kw in sql_keywords):
|
|
489
513
|
all_tables.update(_extract_table_names(content))
|
|
490
|
-
except (FileNotFoundError, PermissionError):
|
|
514
|
+
except (FileNotFoundError, PermissionError, IsADirectoryError, OSError):
|
|
491
515
|
continue
|
|
492
516
|
|
|
493
517
|
cache = _load_schema_cache()
|
|
@@ -30,7 +30,7 @@ import sqlite3
|
|
|
30
30
|
import signal
|
|
31
31
|
import time
|
|
32
32
|
import uuid
|
|
33
|
-
from datetime import datetime, timedelta
|
|
33
|
+
from datetime import datetime, timedelta, timezone
|
|
34
34
|
from email.utils import parseaddr
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
from logging.handlers import RotatingFileHandler
|
|
@@ -1269,6 +1269,8 @@ def _ensure_emails_table(conn):
|
|
|
1269
1269
|
conn.execute("ALTER TABLE emails ADD COLUMN attempts INTEGER DEFAULT 0")
|
|
1270
1270
|
if "error" not in cols:
|
|
1271
1271
|
conn.execute("ALTER TABLE emails ADD COLUMN error TEXT")
|
|
1272
|
+
if "escalation_notified_at" not in cols:
|
|
1273
|
+
conn.execute("ALTER TABLE emails ADD COLUMN escalation_notified_at TEXT")
|
|
1272
1274
|
|
|
1273
1275
|
|
|
1274
1276
|
def _email_table_columns(conn):
|
|
@@ -2356,9 +2358,61 @@ def _mark_needs_interactive(email_ids):
|
|
|
2356
2358
|
log.warning(f"Failed to mark needs_interactive: {e}")
|
|
2357
2359
|
|
|
2358
2360
|
|
|
2361
|
+
def _filter_already_notified(message_ids):
|
|
2362
|
+
"""Return the subset of message_ids that have NOT been escalated yet
|
|
2363
|
+
(i.e. emails.escalation_notified_at IS NULL). Idempotent: if the column
|
|
2364
|
+
is missing for any reason, no row is filtered out."""
|
|
2365
|
+
if not message_ids:
|
|
2366
|
+
return []
|
|
2367
|
+
try:
|
|
2368
|
+
conn = sqlite3.connect(str(EMAIL_DB_PATH))
|
|
2369
|
+
try:
|
|
2370
|
+
_ensure_emails_table(conn)
|
|
2371
|
+
placeholders = ",".join("?" for _ in message_ids)
|
|
2372
|
+
rows = conn.execute(
|
|
2373
|
+
f"""
|
|
2374
|
+
SELECT message_id FROM emails
|
|
2375
|
+
WHERE message_id IN ({placeholders})
|
|
2376
|
+
AND escalation_notified_at IS NULL
|
|
2377
|
+
""",
|
|
2378
|
+
tuple(message_ids),
|
|
2379
|
+
).fetchall()
|
|
2380
|
+
return [r[0] for r in rows]
|
|
2381
|
+
finally:
|
|
2382
|
+
conn.close()
|
|
2383
|
+
except Exception as e:
|
|
2384
|
+
log.warning(f"escalation_notified_at filter failed, falling through: {e}")
|
|
2385
|
+
return list(message_ids)
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
def _mark_escalation_notified(message_ids):
|
|
2389
|
+
"""Stamp emails.escalation_notified_at = now() so we never re-notify
|
|
2390
|
+
the operator about the same exhausted email."""
|
|
2391
|
+
if not message_ids:
|
|
2392
|
+
return
|
|
2393
|
+
try:
|
|
2394
|
+
conn = sqlite3.connect(str(EMAIL_DB_PATH))
|
|
2395
|
+
try:
|
|
2396
|
+
_ensure_emails_table(conn)
|
|
2397
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
2398
|
+
for mid in message_ids:
|
|
2399
|
+
conn.execute(
|
|
2400
|
+
"UPDATE emails SET escalation_notified_at = ? WHERE message_id = ?",
|
|
2401
|
+
(now_iso, mid),
|
|
2402
|
+
)
|
|
2403
|
+
conn.commit()
|
|
2404
|
+
finally:
|
|
2405
|
+
conn.close()
|
|
2406
|
+
except Exception as e:
|
|
2407
|
+
log.warning(f"Failed to stamp escalation_notified_at: {e}")
|
|
2408
|
+
|
|
2409
|
+
|
|
2359
2410
|
def _escalate_exhausted_emails(config, batch):
|
|
2360
2411
|
"""After all retries exhausted, directly escalate emails with attempts >= MAX
|
|
2361
|
-
by marking them needs_interactive and sending email to operator via nexo-send-reply.py.
|
|
2412
|
+
by marking them needs_interactive and sending email to operator via nexo-send-reply.py.
|
|
2413
|
+
|
|
2414
|
+
Deduplicated by emails.escalation_notified_at: if an email is already in
|
|
2415
|
+
needs_interactive state from a previous run, we do not re-notify the operator."""
|
|
2362
2416
|
exhausted = [e for e in batch if (e.get("attempts", 0) + 1) >= MAX_EMAIL_ATTEMPTS]
|
|
2363
2417
|
if not exhausted:
|
|
2364
2418
|
return
|
|
@@ -2366,6 +2420,19 @@ def _escalate_exhausted_emails(config, batch):
|
|
|
2366
2420
|
_mark_needs_interactive(ids)
|
|
2367
2421
|
log.info(f"Marked {len(ids)} email(s) as needs_interactive after exhausting retries")
|
|
2368
2422
|
|
|
2423
|
+
pending_notify_ids = set(_filter_already_notified(ids))
|
|
2424
|
+
if not pending_notify_ids:
|
|
2425
|
+
log.info(
|
|
2426
|
+
f"All {len(ids)} exhausted email(s) already escalated to operator earlier — skipping duplicate notification."
|
|
2427
|
+
)
|
|
2428
|
+
return
|
|
2429
|
+
skipped = len(ids) - len(pending_notify_ids)
|
|
2430
|
+
if skipped:
|
|
2431
|
+
log.info(
|
|
2432
|
+
f"Escalation dedup: {skipped} email(s) already notified, will only escalate {len(pending_notify_ids)} new one(s)."
|
|
2433
|
+
)
|
|
2434
|
+
exhausted = [e for e in exhausted if e["message_id"] in pending_notify_ids]
|
|
2435
|
+
|
|
2369
2436
|
operator_name, assistant_name, operator_language = _get_operator_info()
|
|
2370
2437
|
operator_email = config.get("operator_email", "")
|
|
2371
2438
|
if not operator_email:
|
|
@@ -2387,8 +2454,9 @@ def _escalate_exhausted_emails(config, batch):
|
|
|
2387
2454
|
body_file.write_text(body, encoding="utf-8")
|
|
2388
2455
|
|
|
2389
2456
|
send_script = get_send_reply_script_path(local_script_dir=_script_dir)
|
|
2457
|
+
send_ok = False
|
|
2390
2458
|
try:
|
|
2391
|
-
subprocess.run(
|
|
2459
|
+
result = subprocess.run(
|
|
2392
2460
|
[
|
|
2393
2461
|
sys.executable, str(send_script),
|
|
2394
2462
|
"--to", f"{operator_name} <{operator_email}>",
|
|
@@ -2398,12 +2466,22 @@ def _escalate_exhausted_emails(config, batch):
|
|
|
2398
2466
|
timeout=30,
|
|
2399
2467
|
capture_output=True,
|
|
2400
2468
|
)
|
|
2401
|
-
|
|
2469
|
+
send_ok = (result.returncode == 0)
|
|
2470
|
+
if send_ok:
|
|
2471
|
+
log.info(f"Escalation email sent to {operator_email}")
|
|
2472
|
+
else:
|
|
2473
|
+
log.warning(
|
|
2474
|
+
f"Escalation send returned exit={result.returncode}; "
|
|
2475
|
+
f"stderr={(result.stderr or b'').decode('utf-8', 'replace')[:200]}"
|
|
2476
|
+
)
|
|
2402
2477
|
except Exception as e:
|
|
2403
2478
|
log.warning(f"Failed to send escalation email: {e}")
|
|
2404
2479
|
finally:
|
|
2405
2480
|
body_file.unlink(missing_ok=True)
|
|
2406
2481
|
|
|
2482
|
+
if send_ok:
|
|
2483
|
+
_mark_escalation_notified(list(pending_notify_ids))
|
|
2484
|
+
|
|
2407
2485
|
|
|
2408
2486
|
def main():
|
|
2409
2487
|
log.info("=== Monitor check ===")
|