nexo-brain 7.9.31 → 7.9.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.31",
3
+ "version": "7.9.34",
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,13 @@
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.9.31` is the current packaged-runtime line. Patch release over `7.9.30`: fixes a wire-level bug where ``call_model_raw`` was sending ``stop_sequences=["\n", ".", " "]`` by default, which the current Anthropic Messages API rejects with HTTP 400 ``each stop sequence must contain non-whitespace``. The default is now ``None`` (no ``stop_sequences`` field sent) since ``max_tokens=3`` already caps the yes/no classifier output. A local guard rejects whitespace-only caller values up front so the error shows where the caller is, not as a remote 400. Also removes an internal design document that did not belong in the open-source distribution.
21
+ Version `7.9.34` is the current packaged-runtime line. Patch release with two fixes. First, the email monitor's header parser was dropping any email whose RFC822 headers came back as ``email.header.Header`` instances (Q-encoded utf-8 / quoted-printable, common when sender names or subjects contain non-ASCII). ``msg.get("Message-ID").strip()`` raised ``TypeError: 'Header' object is not subscriptable``, the exception was swallowed at DEBUG, and the email was discarded silently operators only noticed when Nero stopped replying. Every ``msg.get(...)`` now goes through ``_decode_header`` (which decodes Q-encoding AND coerces to ``str``), and the failure log is lifted from DEBUG to WARNING so a future regression cannot drop emails silently. Second, the PreToolUse Guardian gate emitted JSON ``permissionDecision: deny`` on a hard-mode block but exit 0 — terminal Claude Code occasionally proceeded with the next tool anyway because the JSON deny channel was being dropped or out-of-order delivered mid-tool-loop. Hard blocks now also write the structured Guardian reason to stderr and exit 2, the documented PreToolUse blocking exit. Belt-and-suspenders enforcement: the model receives the same Guardian reason through both channels and self-corrects instead of blindly retrying.
22
+
23
+ Previously in `7.9.33`: adds ``usedforsecurity=False`` to the SHA-1 call that derives a filesystem-safe checkpoint filename from the email's Message-ID, so Bandit's B324 audit no longer fails the publish workflow on a non-security usage. The ``v7.9.32`` git tag is preserved for traceability but no npm release ever shipped for it; ``nexo-brain@7.9.33`` is the first release that carries the 7.9.32 email-recovery checkpoints.
24
+
25
+ Previously in `7.9.32`: hardens the email monitor's recovery so emails that fall between Brain releases never end up in a permanent limbo. The periodic ``_recover_unreplied_processed`` sweep now looks back 7 days (was 24h), and every failed worker run persists a per-email checkpoint at ``~/.nexo/nexo-email/checkpoints/`` capturing files touched, last assistant narration, and error. Retry attempts inject that context into the next prompt so a long task (drafting a presentation, multi-step analysis) continues from where the previous attempt died instead of restarting from scratch. Stale checkpoints are pruned automatically after 7 days. 15 new unit tests cover the helpers.
26
+
27
+ Previously in `7.9.31`: fixes a wire-level bug where ``call_model_raw`` was sending ``stop_sequences=["\n", ".", " "]`` by default, which the current Anthropic Messages API rejects with HTTP 400 ``each stop sequence must contain non-whitespace``. The default is now ``None`` (no ``stop_sequences`` field sent) since ``max_tokens=3`` already caps the yes/no classifier output. A local guard rejects whitespace-only caller values up front so the error shows where the caller is, not as a remote 400. Also removes an internal design document that did not belong in the open-source distribution.
22
28
 
23
29
  Previously in `7.9.30`: hotfix for a missing ``import sys`` in ``src/agent_runner.py`` that ruff F821 caught in CI and blocked the 7.9.29 publish workflow before any npm artifact shipped. ``nexo-brain@7.9.30`` is the first npm release that carries the 7.9.29 override-path hardening.
24
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.31",
3
+ "version": "7.9.34",
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",
@@ -143,8 +143,20 @@ def main() -> int:
143
143
  "permissionDecisionReason": "Guardian gate blocked this tool call.",
144
144
  },
145
145
  }))
146
+ # 7.9.34 hardening: terminal Claude Code sometimes ignored the
147
+ # JSON deny channel mid-tool-loop and ran the next tool anyway.
148
+ # Belt-and-suspenders — also write the reason to stderr and exit
149
+ # with code 2, the documented blocking exit for PreToolUse. The
150
+ # JSON response stays the primary contract, but exit 2 forces
151
+ # the block at the process-exit layer and surfaces the reason to
152
+ # the model so it self-corrects instead of retrying blindly.
153
+ try:
154
+ sys.stderr.write(reason + "\n")
155
+ sys.stderr.flush()
156
+ except Exception:
157
+ pass
146
158
  summary = "blocked"
147
- exit_code = 0 # JSON response is the canonical path; non-zero is redundant
159
+ exit_code = 2
148
160
 
149
161
  elif isinstance(result, dict) and result.get("skipped"):
150
162
  summary = f"skipped:{result.get('reason', '')[:40]}"
@@ -74,6 +74,7 @@ EMAIL_DB_PATH = BASE_DIR / "nexo-email.db"
74
74
  LOCK_FILE = BASE_DIR / ".lock"
75
75
  SESSIONS_FILE = BASE_DIR / ".active-sessions.json"
76
76
  WORKER_JOBS_DIR = BASE_DIR / "worker-jobs"
77
+ CHECKPOINTS_DIR = BASE_DIR / "checkpoints"
77
78
  LOG_FILE = BASE_DIR / "monitor.log"
78
79
  ALERT_FILE = BASE_DIR / ".consecutive-failures"
79
80
  EMPTY_BACKOFF_STATE_FILE = BASE_DIR / ".empty-inbox-backoff.json"
@@ -112,6 +113,7 @@ CREATE INDEX IF NOT EXISTS idx_ee_ts ON email_events(timestamp);
112
113
 
113
114
  BASE_DIR.mkdir(parents=True, exist_ok=True)
114
115
  WORKER_JOBS_DIR.mkdir(parents=True, exist_ok=True)
116
+ CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True)
115
117
 
116
118
  # Rotating log: 5MB max, keep 3 backups
117
119
  handler = RotatingFileHandler(str(LOG_FILE), maxBytes=5*1024*1024, backupCount=3)
@@ -121,6 +123,228 @@ log.setLevel(logging.INFO)
121
123
  log.addHandler(handler)
122
124
 
123
125
 
126
+ # ----------------------------------------------------------------------
127
+ # Email checkpoint system
128
+ # ----------------------------------------------------------------------
129
+ # Each email Nexo processes can take a non-trivial amount of work (drafting
130
+ # code, building a presentation, multi-step analysis). When a worker dies
131
+ # mid-flight (Brain release, OOM, timeout, manual reboot) the next retry
132
+ # previously started from scratch — it had no memory of the partial work the
133
+ # previous attempt had already produced. For long replies that meant tokens
134
+ # wasted on re-discovery and, occasionally, half-written files left behind in
135
+ # the working directory with no narrative context.
136
+ #
137
+ # The checkpoint helpers below persist a small JSON record per email-thread
138
+ # at ``~/.nexo/nexo-email/checkpoints/<sha1(message_id)[:16]>.json`` capturing
139
+ # what the previous attempt did so the retry's prompt can include it. The
140
+ # checkpoint is best-effort: if reading or writing fails the worker keeps
141
+ # running, just without the recovery context.
142
+
143
+ import hashlib as _hashlib # alias to keep the public ``hashlib`` import explicit
144
+
145
+
146
+ def _email_checkpoint_path(message_id: str) -> Path:
147
+ """Stable, filesystem-safe path for a given Message-ID.
148
+
149
+ Message-IDs contain ``<``, ``>``, ``@`` and other characters that mix
150
+ badly with filesystems, so we hash them. 16 hex chars (~64 bits) is well
151
+ above the collision threshold for the few hundred emails Nexo handles
152
+ per operator, while keeping filenames short enough to skim in a directory
153
+ listing during a debug session.
154
+ """
155
+ # ``usedforsecurity=False`` declares the hash is purely a filename
156
+ # disambiguator (Message-IDs contain ``<``, ``>``, ``@`` that the FS
157
+ # rejects), not a cryptographic primitive. Bandit B324 flags weak
158
+ # algorithms used for security; this annotation tells it this call
159
+ # is safe by intent.
160
+ digest = _hashlib.sha1( # noqa: S324 - non-security: filename hashing only
161
+ (message_id or "").encode("utf-8"),
162
+ usedforsecurity=False,
163
+ ).hexdigest()[:16]
164
+ return CHECKPOINTS_DIR / f"{digest}.json"
165
+
166
+
167
+ def _email_checkpoint_read(message_id: str) -> dict | None:
168
+ """Return the checkpoint dict for ``message_id`` if one exists, else None.
169
+
170
+ Returns ``None`` (not raise) on any IO/parse failure so the worker can
171
+ treat "no recovery context" as a safe default.
172
+ """
173
+ if not message_id:
174
+ return None
175
+ path = _email_checkpoint_path(message_id)
176
+ try:
177
+ if not path.is_file():
178
+ return None
179
+ return json.loads(path.read_text())
180
+ except (OSError, json.JSONDecodeError) as exc:
181
+ log.warning(f"Checkpoint read failed for {message_id}: {exc}")
182
+ return None
183
+
184
+
185
+ def _email_checkpoint_write(
186
+ *,
187
+ message_id: str,
188
+ subject: str,
189
+ files_touched: list[str],
190
+ last_assistant_text: str,
191
+ last_error: str,
192
+ attempts: int,
193
+ ) -> None:
194
+ """Persist a checkpoint atomically (tmp + rename).
195
+
196
+ Best-effort: any failure is logged at warning level but never raised so
197
+ the worker keeps progressing.
198
+ """
199
+ if not message_id:
200
+ return
201
+ path = _email_checkpoint_path(message_id)
202
+ existing = _email_checkpoint_read(message_id) or {}
203
+ now_iso = datetime.now().isoformat(timespec="seconds")
204
+ payload = {
205
+ "message_id": message_id,
206
+ "subject": str(subject or "")[:200],
207
+ "first_attempt_at": existing.get("first_attempt_at") or now_iso,
208
+ "last_attempt_at": now_iso,
209
+ "attempts": int(attempts or existing.get("attempts", 0) + 1),
210
+ "files_touched": sorted(set(
211
+ list(existing.get("files_touched") or []) + list(files_touched or [])
212
+ ))[:50], # cap so a misbehaving run cannot blow up the checkpoint
213
+ "last_assistant_text": str(last_assistant_text or "")[:4000],
214
+ "last_error": str(last_error or "")[:500],
215
+ }
216
+ try:
217
+ tmp = path.with_suffix(path.suffix + ".tmp")
218
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
219
+ tmp.replace(path)
220
+ except OSError as exc:
221
+ log.warning(f"Checkpoint write failed for {message_id}: {exc}")
222
+
223
+
224
+ def _email_checkpoint_delete(message_id: str) -> None:
225
+ """Remove the checkpoint when an email succeeds or is escalated."""
226
+ if not message_id:
227
+ return
228
+ try:
229
+ _email_checkpoint_path(message_id).unlink(missing_ok=True)
230
+ except OSError as exc:
231
+ log.warning(f"Checkpoint delete failed for {message_id}: {exc}")
232
+
233
+
234
+ def _email_checkpoint_cleanup(*, max_age_days: int = 7) -> int:
235
+ """Drop checkpoint files older than ``max_age_days``. Idempotent.
236
+
237
+ Returns the number of files removed. Called from ``main()`` once per
238
+ monitor tick; on a healthy Mac this is sub-millisecond because the
239
+ directory rarely holds more than a handful of entries.
240
+ """
241
+ if not CHECKPOINTS_DIR.is_dir():
242
+ return 0
243
+ cutoff = time.time() - (max_age_days * 86400)
244
+ removed = 0
245
+ for path in CHECKPOINTS_DIR.glob("*.json"):
246
+ try:
247
+ if path.stat().st_mtime < cutoff:
248
+ path.unlink()
249
+ removed += 1
250
+ except OSError:
251
+ continue
252
+ if removed:
253
+ log.info(f"Checkpoint cleanup: removed {removed} stale file(s) older than {max_age_days}d")
254
+ return removed
255
+
256
+
257
+ def _scan_files_modified_since(working_dir: str | os.PathLike, since_epoch: float, *, max_files: int = 50) -> list[str]:
258
+ """Return absolute paths in ``working_dir`` whose mtime is newer than
259
+ ``since_epoch``. Used after a worker run to capture the files Nexo
260
+ edited or created during the attempt, so a retry can decide whether to
261
+ pick up where it left off.
262
+
263
+ Skips hidden directories, NEXO runtime caches, and Git internals to
264
+ avoid drowning the checkpoint in noise. Caps at ``max_files`` entries
265
+ in case the caller passes a large repository as ``cwd``.
266
+ """
267
+ root = Path(working_dir or "").expanduser()
268
+ if not root.is_dir():
269
+ return []
270
+ skip_dirs = {".git", ".venv", "node_modules", "__pycache__", ".nexo", "Library", "Documents"}
271
+ out: list[str] = []
272
+ try:
273
+ for child in root.rglob("*"):
274
+ try:
275
+ if any(part in skip_dirs for part in child.parts):
276
+ continue
277
+ if child.is_file() and child.stat().st_mtime > since_epoch:
278
+ out.append(str(child))
279
+ if len(out) >= max_files:
280
+ break
281
+ except OSError:
282
+ continue
283
+ except OSError:
284
+ return []
285
+ return out
286
+
287
+
288
+ def _build_previous_progress_block(message_ids: list[str]) -> str:
289
+ """Build a human-readable section describing progress from prior attempts
290
+ on the given message_ids. Returns an empty string if no checkpoints
291
+ exist, so the prompt builder can append it unconditionally."""
292
+ blocks: list[str] = []
293
+ for mid in message_ids or []:
294
+ cp = _email_checkpoint_read(mid)
295
+ if not cp:
296
+ continue
297
+ subject = cp.get("subject") or "(no subject)"
298
+ attempts = cp.get("attempts") or 1
299
+ files = cp.get("files_touched") or []
300
+ last_text = (cp.get("last_assistant_text") or "").strip()
301
+ last_error = (cp.get("last_error") or "").strip()
302
+ section = [
303
+ f"### Previous attempt on email \"{subject}\"",
304
+ f"- Attempts so far: {attempts}",
305
+ ]
306
+ if files:
307
+ section.append(f"- Files the previous attempt touched (may already contain partial work):")
308
+ for f in files[:20]:
309
+ section.append(f" - {f}")
310
+ if last_text:
311
+ section.append("- Last narration captured before the previous attempt died:")
312
+ section.append(" " + last_text.replace("\n", "\n ")[:1500])
313
+ if last_error:
314
+ section.append(f"- Last error: {last_error}")
315
+ section.append(
316
+ "- Decide: continue from where the previous attempt left off (preferred when the partial files are coherent), or start fresh (only if the previous progress is clearly wrong). Either way, do not duplicate work."
317
+ )
318
+ blocks.append("\n".join(section))
319
+ if not blocks:
320
+ return ""
321
+ return "\n\n## Previous attempt context (recovery checkpoint)\n\n" + "\n\n".join(blocks) + "\n"
322
+
323
+
324
+ def _extract_last_assistant_text_from_run(stdout: str) -> str:
325
+ """Best-effort: pull the last assistant-visible text from Claude Code's
326
+ JSON output. Used to give the next attempt's prompt a hint of what the
327
+ dying attempt was thinking. Returns empty string if nothing parseable.
328
+ """
329
+ raw = (stdout or "").strip()
330
+ if not raw or not raw.startswith("{"):
331
+ return raw[:1000]
332
+ try:
333
+ payload = json.loads(raw)
334
+ except json.JSONDecodeError:
335
+ return raw[:1000]
336
+ # Claude Code 1.x: ``{"result": "...text..."}`` is the canonical exit shape.
337
+ result = payload.get("result")
338
+ if isinstance(result, str) and result.strip():
339
+ return result.strip()[:4000]
340
+ if isinstance(result, dict):
341
+ # Some configs return structured result; collect strings.
342
+ flat = " ".join(str(v) for v in result.values() if isinstance(v, str))
343
+ if flat.strip():
344
+ return flat.strip()[:4000]
345
+ return raw[:1000]
346
+
347
+
124
348
  def operator_routing_context() -> str:
125
349
  if not ROUTING_RULES_FILE.exists():
126
350
  return "No special routing rules."
@@ -813,7 +1037,15 @@ def _decode_header(raw):
813
1037
  def _parse_email_headers(raw_bytes):
814
1038
  """Parse minimal headers from RFC822 header bytes. Returns dict with
815
1039
  message_id, from_addr, from_name, subject, received_at, thread_id,
816
- in_reply_to. Empty strings on failure."""
1040
+ in_reply_to. Empty strings on failure.
1041
+
1042
+ Q-encoded headers (utf-8 / quoted-printable) come back as
1043
+ `email.header.Header` instances rather than plain strings. Plain
1044
+ `str(Header)` returns the still-encoded `=?utf-8?q?...?=` form, and
1045
+ `Header` itself does not support `.strip()` / `in` — both of which
1046
+ used to drop the email silently in production. Every `msg.get(...)`
1047
+ therefore goes through `_decode_header`, which decodes Q-encoding
1048
+ AND coerces to a real `str`."""
817
1049
  try:
818
1050
  import email as _email
819
1051
  msg = _email.message_from_bytes(raw_bytes)
@@ -823,17 +1055,21 @@ def _parse_email_headers(raw_bytes):
823
1055
  if "<" in from_raw and ">" in from_raw:
824
1056
  name = from_raw.split("<")[0].strip().strip('"')
825
1057
  addr = from_raw.split("<")[1].split(">")[0].strip()
1058
+ references_raw = _decode_header(msg.get("References", ""))
1059
+ in_reply_to_raw = _decode_header(msg.get("In-Reply-To", ""))
1060
+ thread_seed = (references_raw or in_reply_to_raw).strip()
1061
+ thread_id = thread_seed.split()[-1] if thread_seed else ""
826
1062
  return {
827
- "message_id": (msg.get("Message-ID") or "").strip(),
1063
+ "message_id": _decode_header(msg.get("Message-ID", "")).strip(),
828
1064
  "from_addr": addr.strip().lower(),
829
1065
  "from_name": name,
830
1066
  "subject": _decode_header(msg.get("Subject", "")),
831
- "received_at": (msg.get("Date") or "").strip(),
832
- "in_reply_to": (msg.get("In-Reply-To") or "").strip(),
833
- "thread_id": (msg.get("References") or msg.get("In-Reply-To") or "").strip().split()[-1] if msg.get("References") or msg.get("In-Reply-To") else "",
1067
+ "received_at": _decode_header(msg.get("Date", "")).strip(),
1068
+ "in_reply_to": in_reply_to_raw.strip(),
1069
+ "thread_id": thread_id,
834
1070
  }
835
1071
  except Exception as e:
836
- log.debug(f"Header parse failed: {e}")
1072
+ log.warning(f"Header parse failed: {e}")
837
1073
  return {}
838
1074
 
839
1075
 
@@ -1627,6 +1863,7 @@ def build_processing_prompt(
1627
1863
  debt_block: str = "",
1628
1864
  routing_rules: str = "",
1629
1865
  recent_hot_context: str = "",
1866
+ previous_progress_block: str = "",
1630
1867
  ) -> str:
1631
1868
  interactive_emails = list(needs_interactive or [])
1632
1869
  target_items = list(target_emails or [])
@@ -1680,7 +1917,12 @@ def build_processing_prompt(
1680
1917
  send_reply_script=send_reply_script,
1681
1918
  trusted_domains_label=trusted_domains_label,
1682
1919
  routing_rules=routing_rules or "No special routing rules.",
1683
- extra_instructions_block=("\n" + extra_instructions_block.strip() + "\n") if extra_instructions_block.strip() else "",
1920
+ extra_instructions_block=(
1921
+ (
1922
+ ("\n" + extra_instructions_block.strip() + "\n") if extra_instructions_block.strip() else ""
1923
+ )
1924
+ + (previous_progress_block or "")
1925
+ ),
1684
1926
  target_block=target_block,
1685
1927
  interactive_block=interactive_block,
1686
1928
  debt_block=(f"\n{debt_block.strip()}\n" if str(debt_block or "").strip() else ""),
@@ -1715,6 +1957,13 @@ def launch_nexo(config, debt_block="", target_emails=None):
1715
1957
 
1716
1958
  routing_rules = operator_routing_context()
1717
1959
  recent_hot_context = read_recent_hot_context(query="", hours=24, limit=10)
1960
+ target_message_ids = [str(e.get("message_id") or "") for e in (target_emails or []) if e.get("message_id")]
1961
+ previous_progress_block = _build_previous_progress_block(target_message_ids)
1962
+ if previous_progress_block:
1963
+ log.info(
1964
+ f"Resuming from checkpoint(s) for {len(target_message_ids)} email(s); "
1965
+ "previous attempt context attached to prompt."
1966
+ )
1718
1967
  prompt = build_processing_prompt(
1719
1968
  config=config,
1720
1969
  operator_name=operator_name,
@@ -1734,7 +1983,10 @@ def launch_nexo(config, debt_block="", target_emails=None):
1734
1983
  debt_block=debt_block,
1735
1984
  routing_rules=routing_rules,
1736
1985
  recent_hot_context=recent_hot_context,
1986
+ previous_progress_block=previous_progress_block,
1737
1987
  )
1988
+ working_dir = config.get("working_dir", str(Path.home()))
1989
+ run_started_at = time.time()
1738
1990
 
1739
1991
  env = os.environ.copy()
1740
1992
  env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
@@ -1763,6 +2015,29 @@ def launch_nexo(config, debt_block="", target_emails=None):
1763
2015
  requested_timeout = int(config.get("max_process_time", MAX_AUTOMATION_TIMEOUT_SECONDS) or MAX_AUTOMATION_TIMEOUT_SECONDS)
1764
2016
  effective_timeout = max(60, min(requested_timeout, MAX_AUTOMATION_TIMEOUT_SECONDS))
1765
2017
 
2018
+ def _persist_failure_checkpoints(*, error_msg: str, last_text: str) -> None:
2019
+ """Capture per-email checkpoint when the run did not complete OK so
2020
+ the next attempt's prompt carries the previous attempt's progress.
2021
+ Best-effort: never raises out of here."""
2022
+ if not target_message_ids:
2023
+ return
2024
+ try:
2025
+ files_touched = _scan_files_modified_since(working_dir, run_started_at)
2026
+ except Exception:
2027
+ files_touched = []
2028
+ for em in target_emails or []:
2029
+ mid = str(em.get("message_id") or "")
2030
+ if not mid:
2031
+ continue
2032
+ _email_checkpoint_write(
2033
+ message_id=mid,
2034
+ subject=str(em.get("subject") or ""),
2035
+ files_touched=files_touched,
2036
+ last_assistant_text=last_text,
2037
+ last_error=error_msg,
2038
+ attempts=int((em.get("attempts") or 0) + 1),
2039
+ )
2040
+
1766
2041
  try:
1767
2042
  result = run_automation_prompt(
1768
2043
  prompt,
@@ -1781,17 +2056,33 @@ def launch_nexo(config, debt_block="", target_emails=None):
1781
2056
  log.error(f"NEXO exit code {result.returncode}")
1782
2057
  if result.stderr:
1783
2058
  log.error(f"stderr: {result.stderr[:500]}")
2059
+ _persist_failure_checkpoints(
2060
+ error_msg=f"exit {result.returncode}: {(result.stderr or '')[:200]}",
2061
+ last_text=_extract_last_assistant_text_from_run(result.stdout or ""),
2062
+ )
1784
2063
  return False
2064
+ # Success: drop checkpoints for the emails the worker just handled,
2065
+ # so the recovery context does not leak into a future, unrelated
2066
+ # attempt on the same Message-ID (rare, but possible after a
2067
+ # status reset by ``_recover_unreplied_processed``).
2068
+ for mid in target_message_ids:
2069
+ _email_checkpoint_delete(mid)
1785
2070
  return True
1786
2071
 
1787
2072
  except AutomationBackendUnavailableError as e:
1788
2073
  log.error(f"Automation backend unavailable: {e}")
2074
+ _persist_failure_checkpoints(error_msg=f"AutomationBackendUnavailable: {e}", last_text="")
1789
2075
  return False
1790
2076
  except subprocess.TimeoutExpired:
1791
2077
  log.error(f"Email automation exceeded {effective_timeout}s and was terminated")
2078
+ _persist_failure_checkpoints(
2079
+ error_msg=f"timeout after {effective_timeout}s",
2080
+ last_text="",
2081
+ )
1792
2082
  return False
1793
2083
  except Exception as e:
1794
2084
  log.error(f"Launch error: {e}")
2085
+ _persist_failure_checkpoints(error_msg=f"unexpected: {e}", last_text="")
1795
2086
  return False
1796
2087
  def track_failure(success):
1797
2088
  """Track consecutive failures. Alert if 3+ in a row."""
@@ -1914,8 +2205,18 @@ def main():
1914
2205
 
1915
2206
  reconcile_orphaned_seen(config, hours=24)
1916
2207
  reconcile_terminal_unseen(config, hours=48)
1917
- _recover_unreplied_processed(config, hours=24)
2208
+ # Recovery window widened from 24h to 7 days (168h): a single email can
2209
+ # fall between several Brain releases in a short window (4 releases in
2210
+ # one day on 2026-04-26). The 24h sweep let those drop into a permanent
2211
+ # limbo because the next sweep happened after the email was already
2212
+ # outside the lookback. 7 days is large enough to absorb a normal
2213
+ # release cadence while still small enough that very old "stuck"
2214
+ # emails are not retried indefinitely. Companion checkpoint system in
2215
+ # ``_email_checkpoint_*`` lets a retried email continue from the
2216
+ # previous attempt's progress instead of restarting from scratch.
2217
+ _recover_unreplied_processed(config, hours=168)
1918
2218
  preregistered_count = preregister_pending_emails(config)
2219
+ _email_checkpoint_cleanup(max_age_days=7)
1919
2220
 
1920
2221
  # --- Concurrency check ---
1921
2222
  active_count = _active_session_count()