nexo-brain 7.9.10 → 7.9.12
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 +1 -1
- package/bin/nexo-brain.js +25 -0
- package/package.json +1 -1
- package/src/agent_runner.py +2 -0
- package/src/auto_update.py +25 -0
- package/src/cli.py +46 -0
- package/src/enforcement_engine.py +6 -0
- package/src/lifecycle_events.py +186 -7
- package/src/lifecycle_prompts.py +11 -1
- package/src/plugins/lifecycle_events.py +52 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.12",
|
|
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,7 @@
|
|
|
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.
|
|
21
|
+
Version `7.9.12` is the current packaged-runtime line. Patch release over `7.9.11`: managed `~/.nexo/bin/nexo` wrappers now self-repair `core/current` when it lags behind `~/.nexo/core`, so installed users stop executing stale snapshots and `nexo update` no longer gets stuck claiming “Already up to date” from the old runtime. Coordinated Desktop release: v0.28.13.
|
|
22
22
|
|
|
23
23
|
Previously in `7.9.5`: patch release that fixes canonical diary confirmation for Desktop: Brain resolves the Desktop/Claude session UUID through NEXO SID aliases before checking `session_diary`, so archive/delete/app-exit can confirm diaries written by `nexo_session_diary_write` under the active `nexo-...` SID. Verification: `pytest tests/test_lifecycle_events.py` (28 passing) plus coordinated Desktop v0.28.6 shutdown/archive/delete/app-exit checks.
|
|
24
24
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -3442,6 +3442,31 @@ async function runSetup() {
|
|
|
3442
3442
|
' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2',
|
|
3443
3443
|
' exit 1',
|
|
3444
3444
|
'fi',
|
|
3445
|
+
'read_runtime_version() {',
|
|
3446
|
+
' local base="${1:-}"',
|
|
3447
|
+
' [ -n "$base" ] || return 0',
|
|
3448
|
+
' local candidate=""',
|
|
3449
|
+
' for candidate in "$base/version.json" "$base/package.json"; do',
|
|
3450
|
+
' [ -f "$candidate" ] || continue',
|
|
3451
|
+
' "$PYTHON" -c "import json, sys; from pathlib import Path; payload=json.loads(Path(sys.argv[1]).read_text(encoding=\\"utf-8\\")); version=str(payload.get(\\"version\\", \\"\\")).strip(); sys.stdout.write(version); sys.exit(0 if version else 1)" "$candidate" 2>/dev/null || continue',
|
|
3452
|
+
' return 0',
|
|
3453
|
+
' done',
|
|
3454
|
+
' return 0',
|
|
3455
|
+
'}',
|
|
3456
|
+
'repair_stale_current_runtime() {',
|
|
3457
|
+
' local core_root="$NEXO_HOME/core"',
|
|
3458
|
+
' local current_root="$NEXO_HOME/core/current"',
|
|
3459
|
+
' [ -d "$core_root" ] || return 0',
|
|
3460
|
+
' [ -e "$current_root" ] || return 0',
|
|
3461
|
+
' local core_version=""',
|
|
3462
|
+
' local current_version=""',
|
|
3463
|
+
' core_version="$(read_runtime_version "$core_root")"',
|
|
3464
|
+
' current_version="$(read_runtime_version "$current_root")"',
|
|
3465
|
+
' [ -n "$core_version" ] || return 0',
|
|
3466
|
+
' [ "$core_version" = "$current_version" ] && return 0',
|
|
3467
|
+
' NEXO_HOME="$NEXO_HOME" NEXO_CODE="$core_root" "$PYTHON" -c "import os, sys; from pathlib import Path; home=Path(os.environ[\\"NEXO_HOME\\"]); core=home / \\"core\\"; sys.path.insert(0, str(core)); from runtime_versioning import activate_versioned_runtime_snapshot, read_version_for_path; version=read_version_for_path(core); result=activate_versioned_runtime_snapshot(source_root=core, version=str(version or \\"\\").strip()); sys.exit(0 if result.get(\\"ok\\") else 1)" >/dev/null 2>&1 || return 0',
|
|
3468
|
+
'}',
|
|
3469
|
+
'repair_stale_current_runtime',
|
|
3445
3470
|
'CLI_PY="$NEXO_CODE/cli.py"',
|
|
3446
3471
|
'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/core/current/cli.py" ]; then',
|
|
3447
3472
|
' NEXO_CODE="$NEXO_HOME/core/current"',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.12",
|
|
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
|
@@ -792,6 +792,8 @@ def _build_enforcement_system_prompt() -> str:
|
|
|
792
792
|
map_path = NEXO_HOME / "tool-enforcement-map.json"
|
|
793
793
|
if not map_path.exists():
|
|
794
794
|
for candidate in [
|
|
795
|
+
# .resolve() handles the $NEXO_HOME symlink -> core/ case.
|
|
796
|
+
Path(__file__).resolve().parent / "tool-enforcement-map.json",
|
|
795
797
|
Path(__file__).parent / "tool-enforcement-map.json",
|
|
796
798
|
Path(__file__).parent.parent / "tool-enforcement-map.json",
|
|
797
799
|
]:
|
package/src/auto_update.py
CHANGED
|
@@ -593,6 +593,31 @@ def _runtime_cli_wrapper_text() -> str:
|
|
|
593
593
|
' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2\n'
|
|
594
594
|
' exit 1\n'
|
|
595
595
|
'fi\n'
|
|
596
|
+
'read_runtime_version() {\n'
|
|
597
|
+
' local base="${1:-}"\n'
|
|
598
|
+
' [ -n "$base" ] || return 0\n'
|
|
599
|
+
' local candidate=""\n'
|
|
600
|
+
' for candidate in "$base/version.json" "$base/package.json"; do\n'
|
|
601
|
+
' [ -f "$candidate" ] || continue\n'
|
|
602
|
+
' "$PYTHON" -c "import json, sys; from pathlib import Path; payload=json.loads(Path(sys.argv[1]).read_text(encoding=\\"utf-8\\")); version=str(payload.get(\\"version\\", \\"\\")).strip(); sys.stdout.write(version); sys.exit(0 if version else 1)" "$candidate" 2>/dev/null || continue\n'
|
|
603
|
+
' return 0\n'
|
|
604
|
+
' done\n'
|
|
605
|
+
' return 0\n'
|
|
606
|
+
'}\n'
|
|
607
|
+
'repair_stale_current_runtime() {\n'
|
|
608
|
+
' local core_root="$NEXO_HOME/core"\n'
|
|
609
|
+
' local current_root="$NEXO_HOME/core/current"\n'
|
|
610
|
+
' [ -d "$core_root" ] || return 0\n'
|
|
611
|
+
' [ -e "$current_root" ] || return 0\n'
|
|
612
|
+
' local core_version=""\n'
|
|
613
|
+
' local current_version=""\n'
|
|
614
|
+
' core_version="$(read_runtime_version "$core_root")"\n'
|
|
615
|
+
' current_version="$(read_runtime_version "$current_root")"\n'
|
|
616
|
+
' [ -n "$core_version" ] || return 0\n'
|
|
617
|
+
' [ "$core_version" = "$current_version" ] && return 0\n'
|
|
618
|
+
' NEXO_HOME="$NEXO_HOME" NEXO_CODE="$core_root" "$PYTHON" -c "import os, sys; from pathlib import Path; home=Path(os.environ[\\"NEXO_HOME\\"]); core=home / \\"core\\"; sys.path.insert(0, str(core)); from runtime_versioning import activate_versioned_runtime_snapshot, read_version_for_path; version=read_version_for_path(core); result=activate_versioned_runtime_snapshot(source_root=core, version=str(version or \\"\\").strip()); sys.exit(0 if result.get(\\"ok\\") else 1)" >/dev/null 2>&1 || return 0\n'
|
|
619
|
+
'}\n'
|
|
620
|
+
'repair_stale_current_runtime\n'
|
|
596
621
|
'CLI_PY="$NEXO_CODE/cli.py"\n'
|
|
597
622
|
'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/core/current/cli.py" ]; then\n'
|
|
598
623
|
' NEXO_CODE="$NEXO_HOME/core/current"\n'
|
package/src/cli.py
CHANGED
|
@@ -3232,6 +3232,20 @@ def main():
|
|
|
3232
3232
|
lwait_p.add_argument("--timeout-ms", type=int, default=45_000)
|
|
3233
3233
|
lwait_p.add_argument("--poll-ms", type=int, default=500)
|
|
3234
3234
|
|
|
3235
|
+
lwait_stop_p = lifecycle_sub.add_parser(
|
|
3236
|
+
"wait-for-stop",
|
|
3237
|
+
help="v7.9.10: wait until the linked NEXO session is no longer active",
|
|
3238
|
+
)
|
|
3239
|
+
lwait_stop_p.add_argument("--event-id", required=True)
|
|
3240
|
+
lwait_stop_p.add_argument("--timeout-ms", type=int, default=10_000)
|
|
3241
|
+
lwait_stop_p.add_argument("--poll-ms", type=int, default=500)
|
|
3242
|
+
|
|
3243
|
+
lstop_sid_p = lifecycle_sub.add_parser(
|
|
3244
|
+
"stop-nexo-session",
|
|
3245
|
+
help="v7.9.10: explicit best-effort stop of a NEXO SID for Desktop lifecycle cleanup",
|
|
3246
|
+
)
|
|
3247
|
+
lstop_sid_p.add_argument("--sid", required=True)
|
|
3248
|
+
|
|
3235
3249
|
# Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
|
|
3236
3250
|
quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
|
|
3237
3251
|
quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
|
|
@@ -3533,6 +3547,38 @@ def main():
|
|
|
3533
3547
|
if status == "retryable_error":
|
|
3534
3548
|
return 2
|
|
3535
3549
|
return 3
|
|
3550
|
+
if args.lifecycle_command == "wait-for-stop":
|
|
3551
|
+
out = _lifecycle_plugin.handle_nexo_lifecycle_wait_for_stop(
|
|
3552
|
+
event_id=args.event_id,
|
|
3553
|
+
timeout_ms=args.timeout_ms,
|
|
3554
|
+
poll_ms=args.poll_ms,
|
|
3555
|
+
)
|
|
3556
|
+
print(out)
|
|
3557
|
+
try:
|
|
3558
|
+
parsed = _json.loads(out)
|
|
3559
|
+
status = str(parsed.get("status", ""))
|
|
3560
|
+
except Exception:
|
|
3561
|
+
status = ""
|
|
3562
|
+
if status == "ok":
|
|
3563
|
+
return 0
|
|
3564
|
+
if status == "retryable_error":
|
|
3565
|
+
return 2
|
|
3566
|
+
return 3
|
|
3567
|
+
if args.lifecycle_command == "stop-nexo-session":
|
|
3568
|
+
out = _lifecycle_plugin.handle_nexo_lifecycle_stop_nexo_session(
|
|
3569
|
+
sid=args.sid,
|
|
3570
|
+
)
|
|
3571
|
+
print(out)
|
|
3572
|
+
try:
|
|
3573
|
+
parsed = _json.loads(out)
|
|
3574
|
+
status = str(parsed.get("status", ""))
|
|
3575
|
+
except Exception:
|
|
3576
|
+
status = ""
|
|
3577
|
+
if status == "ok":
|
|
3578
|
+
return 0
|
|
3579
|
+
if status == "retryable_error":
|
|
3580
|
+
return 2
|
|
3581
|
+
return 3
|
|
3536
3582
|
lifecycle_parser.print_help()
|
|
3537
3583
|
return 1
|
|
3538
3584
|
elif args.command in ("schema", "identity", "onboard", "scan-profile"):
|
|
@@ -321,7 +321,13 @@ def _redact_for_log(text: str, max_len: int = 200) -> str:
|
|
|
321
321
|
|
|
322
322
|
|
|
323
323
|
def _load_map() -> dict | None:
|
|
324
|
+
# .resolve() is required: at runtime this module is usually imported via
|
|
325
|
+
# the symlink $NEXO_HOME/enforcement_engine.py -> core/enforcement_engine.py,
|
|
326
|
+
# so the non-resolved Path(__file__).parent points at NEXO_HOME instead
|
|
327
|
+
# of the core/ dir where the map actually sits. Keeping the non-resolved
|
|
328
|
+
# variant too covers in-repo test imports.
|
|
324
329
|
for candidate in [
|
|
330
|
+
Path(__file__).resolve().parent / MAP_FILENAME,
|
|
325
331
|
Path(__file__).parent / MAP_FILENAME,
|
|
326
332
|
paths.home() / MAP_FILENAME,
|
|
327
333
|
paths.brain_dir() / MAP_FILENAME,
|
package/src/lifecycle_events.py
CHANGED
|
@@ -55,6 +55,7 @@ import time
|
|
|
55
55
|
from typing import Any, Dict, List, Optional
|
|
56
56
|
|
|
57
57
|
from db import get_db
|
|
58
|
+
from db._core import SESSION_STALE_SECONDS
|
|
58
59
|
import lifecycle_prompts
|
|
59
60
|
|
|
60
61
|
|
|
@@ -259,6 +260,103 @@ def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str], ac
|
|
|
259
260
|
return _session_diary_evidence(conn, session_id, dispatched_at, actions_json) is not None
|
|
260
261
|
|
|
261
262
|
|
|
263
|
+
def _session_stop_state(conn, session_id: str) -> Dict[str, Any]:
|
|
264
|
+
"""Return whether the lifecycle session can be verified as fully stopped."""
|
|
265
|
+
raw = str(session_id or "").strip()
|
|
266
|
+
if not raw:
|
|
267
|
+
return {
|
|
268
|
+
"verifiable": False,
|
|
269
|
+
"session_registered": False,
|
|
270
|
+
"stop_confirmed": False,
|
|
271
|
+
"active_session_ids": [],
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
candidate_ids: List[str] = []
|
|
275
|
+
verifiable = False
|
|
276
|
+
|
|
277
|
+
if raw.startswith("nexo-"):
|
|
278
|
+
candidate_ids.append(raw)
|
|
279
|
+
verifiable = True
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
rows = conn.execute(
|
|
283
|
+
"SELECT sid FROM session_claude_aliases "
|
|
284
|
+
"WHERE claude_session_id = ? ORDER BY last_seen DESC",
|
|
285
|
+
(raw,),
|
|
286
|
+
).fetchall()
|
|
287
|
+
alias_ids = [str(row[0]) for row in rows if row and row[0]]
|
|
288
|
+
if alias_ids:
|
|
289
|
+
candidate_ids.extend(alias_ids)
|
|
290
|
+
verifiable = True
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
rows = conn.execute(
|
|
296
|
+
"SELECT sid FROM sessions "
|
|
297
|
+
"WHERE external_session_id = ? OR claude_session_id = ? OR sid = ? "
|
|
298
|
+
"ORDER BY last_update_epoch DESC",
|
|
299
|
+
(raw, raw, raw),
|
|
300
|
+
).fetchall()
|
|
301
|
+
live_ids = [str(row[0]) for row in rows if row and row[0]]
|
|
302
|
+
if live_ids:
|
|
303
|
+
candidate_ids.extend(live_ids)
|
|
304
|
+
verifiable = True
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
deduped_candidates: List[str] = []
|
|
309
|
+
seen = set()
|
|
310
|
+
for sid in candidate_ids:
|
|
311
|
+
if sid and sid not in seen:
|
|
312
|
+
seen.add(sid)
|
|
313
|
+
deduped_candidates.append(sid)
|
|
314
|
+
|
|
315
|
+
if not verifiable:
|
|
316
|
+
return {
|
|
317
|
+
"verifiable": False,
|
|
318
|
+
"session_registered": False,
|
|
319
|
+
"stop_confirmed": False,
|
|
320
|
+
"active_session_ids": [],
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
cutoff = time.time() - float(SESSION_STALE_SECONDS)
|
|
324
|
+
active_ids: List[str] = []
|
|
325
|
+
try:
|
|
326
|
+
if deduped_candidates:
|
|
327
|
+
placeholders = ",".join("?" for _ in deduped_candidates)
|
|
328
|
+
rows = conn.execute(
|
|
329
|
+
"SELECT sid FROM sessions "
|
|
330
|
+
f"WHERE last_update_epoch > ? AND (sid IN ({placeholders}) OR external_session_id = ? OR claude_session_id = ?) "
|
|
331
|
+
"ORDER BY last_update_epoch DESC",
|
|
332
|
+
(cutoff, *deduped_candidates, raw, raw),
|
|
333
|
+
).fetchall()
|
|
334
|
+
else:
|
|
335
|
+
rows = conn.execute(
|
|
336
|
+
"SELECT sid FROM sessions "
|
|
337
|
+
"WHERE last_update_epoch > ? AND (external_session_id = ? OR claude_session_id = ?) "
|
|
338
|
+
"ORDER BY last_update_epoch DESC",
|
|
339
|
+
(cutoff, raw, raw),
|
|
340
|
+
).fetchall()
|
|
341
|
+
active_ids = [str(row[0]) for row in rows if row and row[0]]
|
|
342
|
+
except Exception:
|
|
343
|
+
active_ids = []
|
|
344
|
+
|
|
345
|
+
deduped_active: List[str] = []
|
|
346
|
+
seen.clear()
|
|
347
|
+
for sid in active_ids:
|
|
348
|
+
if sid and sid not in seen:
|
|
349
|
+
seen.add(sid)
|
|
350
|
+
deduped_active.append(sid)
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
"verifiable": True,
|
|
354
|
+
"session_registered": True,
|
|
355
|
+
"stop_confirmed": len(deduped_active) == 0,
|
|
356
|
+
"active_session_ids": deduped_active,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
262
360
|
def record_lifecycle_event(
|
|
263
361
|
event_id: str,
|
|
264
362
|
action: str,
|
|
@@ -324,17 +422,18 @@ def record_lifecycle_event(
|
|
|
324
422
|
"prior_status": status,
|
|
325
423
|
}
|
|
326
424
|
|
|
327
|
-
# Case B: canonical was dispatched but never confirmed.
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
# must NOT ask Desktop to re-run the plan.
|
|
425
|
+
# Case B: canonical was dispatched but never confirmed. Only
|
|
426
|
+
# short-circuit if the session already wrote the diary AND no
|
|
427
|
+
# linked NEXO session remains active.
|
|
331
428
|
if prior_plan_id and prior_dispatched_at and not prior_done_at:
|
|
429
|
+
stop_state = _session_stop_state(conn, str(session_id)) if session_id else None
|
|
430
|
+
stop_confirmed = bool(stop_state and stop_state.get("verifiable") and stop_state.get("stop_confirmed"))
|
|
332
431
|
if session_id and _session_diary_since(
|
|
333
432
|
conn,
|
|
334
433
|
str(session_id),
|
|
335
434
|
str(prior_dispatched_at),
|
|
336
435
|
str(prior_actions_json or ""),
|
|
337
|
-
):
|
|
436
|
+
) and stop_confirmed:
|
|
338
437
|
conn.execute(
|
|
339
438
|
"UPDATE lifecycle_events "
|
|
340
439
|
"SET delivery_status = 'already_processed', "
|
|
@@ -349,7 +448,7 @@ def record_lifecycle_event(
|
|
|
349
448
|
"event_id": event_id,
|
|
350
449
|
"duplicate": True,
|
|
351
450
|
"prior_status": status,
|
|
352
|
-
"reason": "session_diary-already-written",
|
|
451
|
+
"reason": "session_diary-and-stop-already-written",
|
|
353
452
|
}
|
|
354
453
|
# Re-hand the exact same plan so Desktop can resume / finish
|
|
355
454
|
# any actions it didn't complete before the crash.
|
|
@@ -538,11 +637,18 @@ def record_complete_canonical(
|
|
|
538
637
|
for r in results_list
|
|
539
638
|
)
|
|
540
639
|
diary_evidence = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
|
|
640
|
+
stop_state = _session_stop_state(conn, session_id) if session_id else {
|
|
641
|
+
"verifiable": False,
|
|
642
|
+
"session_registered": False,
|
|
643
|
+
"stop_confirmed": True,
|
|
644
|
+
"active_session_ids": [],
|
|
645
|
+
}
|
|
541
646
|
diary_required = action in _DIARY_TRIGGERING and bool(session_id)
|
|
542
647
|
session_registered = _session_is_linked_to_nexo(conn, session_id) if diary_required else bool(session_id)
|
|
543
648
|
session_unregistered = diary_required and not session_registered
|
|
544
649
|
diary_missing = diary_required and diary_evidence is None
|
|
545
|
-
|
|
650
|
+
stop_missing = diary_required and bool(stop_state.get("verifiable")) and not bool(stop_state.get("stop_confirmed"))
|
|
651
|
+
effective = "retryable_error" if (any_failure or diary_missing or stop_missing) else "canonical_done"
|
|
546
652
|
last_error = None
|
|
547
653
|
if session_unregistered:
|
|
548
654
|
last_error = SESSION_NOT_LINKED_REASON
|
|
@@ -550,6 +656,8 @@ def record_complete_canonical(
|
|
|
550
656
|
last_error = "one-or-more-actions-failed"
|
|
551
657
|
elif diary_missing:
|
|
552
658
|
last_error = "canonical-diary-not-confirmed"
|
|
659
|
+
elif stop_missing:
|
|
660
|
+
last_error = "canonical-stop-not-confirmed"
|
|
553
661
|
conn.execute(
|
|
554
662
|
"UPDATE lifecycle_events "
|
|
555
663
|
"SET delivery_status = ?, "
|
|
@@ -573,8 +681,11 @@ def record_complete_canonical(
|
|
|
573
681
|
"failed_actions": any_failure,
|
|
574
682
|
"diary_confirmed": diary_evidence is not None,
|
|
575
683
|
"diary_required": diary_required,
|
|
684
|
+
"stop_confirmed": not stop_missing,
|
|
685
|
+
"stop_required": diary_required,
|
|
576
686
|
"session_registered": session_registered,
|
|
577
687
|
"session_diary_id": diary_evidence.get("session_diary_id") if diary_evidence else None,
|
|
688
|
+
"active_session_ids": list(stop_state.get("active_session_ids") or []),
|
|
578
689
|
"reason": last_error if effective == "retryable_error" else None,
|
|
579
690
|
}
|
|
580
691
|
|
|
@@ -635,6 +746,74 @@ def wait_for_canonical_diary(
|
|
|
635
746
|
time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
|
|
636
747
|
|
|
637
748
|
|
|
749
|
+
def wait_for_canonical_stop(
|
|
750
|
+
event_id: str,
|
|
751
|
+
timeout_ms: int = 10_000,
|
|
752
|
+
poll_ms: int = 500,
|
|
753
|
+
) -> Dict[str, Any]:
|
|
754
|
+
"""Poll until the canonical lifecycle session no longer has active linked SIDs."""
|
|
755
|
+
if not event_id:
|
|
756
|
+
return {"status": "rejected", "reason": "missing-event-id"}
|
|
757
|
+
|
|
758
|
+
timeout_s = max(0.0, float(timeout_ms or 0) / 1000.0)
|
|
759
|
+
poll_s = max(0.05, float(poll_ms or 500) / 1000.0)
|
|
760
|
+
deadline = time.monotonic() + timeout_s
|
|
761
|
+
|
|
762
|
+
while True:
|
|
763
|
+
conn = get_db()
|
|
764
|
+
row = conn.execute(
|
|
765
|
+
"SELECT session_id, action FROM lifecycle_events WHERE event_id = ?",
|
|
766
|
+
(str(event_id),),
|
|
767
|
+
).fetchone()
|
|
768
|
+
if row is None:
|
|
769
|
+
return {"status": "rejected", "reason": "unknown-event-id", "event_id": event_id}
|
|
770
|
+
|
|
771
|
+
session_id = str(row[0] or "")
|
|
772
|
+
action = str(row[1] or "")
|
|
773
|
+
stop_required = action in _DIARY_TRIGGERING and bool(session_id)
|
|
774
|
+
if not stop_required:
|
|
775
|
+
return {
|
|
776
|
+
"status": "ok",
|
|
777
|
+
"event_id": event_id,
|
|
778
|
+
"stop_required": False,
|
|
779
|
+
"stop_confirmed": True,
|
|
780
|
+
"session_registered": bool(session_id),
|
|
781
|
+
"active_session_ids": [],
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
stop_state = _session_stop_state(conn, session_id)
|
|
785
|
+
if not stop_state.get("verifiable"):
|
|
786
|
+
return {
|
|
787
|
+
"status": "retryable_error",
|
|
788
|
+
"event_id": event_id,
|
|
789
|
+
"stop_required": True,
|
|
790
|
+
"stop_confirmed": False,
|
|
791
|
+
"session_registered": False,
|
|
792
|
+
"active_session_ids": [],
|
|
793
|
+
"reason": SESSION_NOT_LINKED_REASON,
|
|
794
|
+
}
|
|
795
|
+
if stop_state.get("stop_confirmed"):
|
|
796
|
+
return {
|
|
797
|
+
"status": "ok",
|
|
798
|
+
"event_id": event_id,
|
|
799
|
+
"stop_required": True,
|
|
800
|
+
"stop_confirmed": True,
|
|
801
|
+
"session_registered": True,
|
|
802
|
+
"active_session_ids": [],
|
|
803
|
+
}
|
|
804
|
+
if time.monotonic() >= deadline:
|
|
805
|
+
return {
|
|
806
|
+
"status": "retryable_error",
|
|
807
|
+
"event_id": event_id,
|
|
808
|
+
"stop_required": True,
|
|
809
|
+
"stop_confirmed": False,
|
|
810
|
+
"session_registered": True,
|
|
811
|
+
"active_session_ids": list(stop_state.get("active_session_ids") or []),
|
|
812
|
+
"reason": "canonical-stop-not-confirmed",
|
|
813
|
+
}
|
|
814
|
+
time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
|
|
815
|
+
|
|
816
|
+
|
|
638
817
|
def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
|
|
639
818
|
if not event_id:
|
|
640
819
|
return None
|
package/src/lifecycle_prompts.py
CHANGED
|
@@ -19,7 +19,7 @@ import json
|
|
|
19
19
|
from typing import Any, Dict, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
PLAN_VERSION =
|
|
22
|
+
PLAN_VERSION = 4
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# Actions that trigger a canonical diary+stop plan. `switch` and
|
|
@@ -37,6 +37,7 @@ DEFAULT_RESUME_TIMEOUT_MS = 2_000
|
|
|
37
37
|
DEFAULT_INJECT_TIMEOUT_MS = 30_000
|
|
38
38
|
DEFAULT_DIARY_WAIT_TIMEOUT_MS = 45_000
|
|
39
39
|
DEFAULT_STOP_TIMEOUT_MS = 3_000
|
|
40
|
+
DEFAULT_STOP_WAIT_TIMEOUT_MS = 10_000
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def canonical_plan_id(event_id: str, plan_version: int = PLAN_VERSION) -> str:
|
|
@@ -150,6 +151,15 @@ def build_canonical_plan(
|
|
|
150
151
|
evidence="session_diary",
|
|
151
152
|
),
|
|
152
153
|
_canonical_action("a4", "stop_session", str(session_id), DEFAULT_STOP_TIMEOUT_MS),
|
|
154
|
+
_canonical_action(
|
|
155
|
+
"a5",
|
|
156
|
+
"wait_for_stop",
|
|
157
|
+
str(session_id),
|
|
158
|
+
DEFAULT_STOP_WAIT_TIMEOUT_MS,
|
|
159
|
+
event_id=str(event_id),
|
|
160
|
+
expected_tool_call="nexo_stop",
|
|
161
|
+
evidence="session_stop",
|
|
162
|
+
),
|
|
153
163
|
]
|
|
154
164
|
return {
|
|
155
165
|
"canonical_plan_id": canonical_plan_id(event_id, PLAN_VERSION),
|
|
@@ -168,6 +168,48 @@ def handle_nexo_lifecycle_wait_for_diary(
|
|
|
168
168
|
return json.dumps(ack, ensure_ascii=False)
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
def handle_nexo_lifecycle_wait_for_stop(
|
|
172
|
+
event_id: str,
|
|
173
|
+
timeout_ms: int = 10_000,
|
|
174
|
+
poll_ms: int = 500,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Wait until a canonical lifecycle event no longer has an active NEXO session."""
|
|
177
|
+
try:
|
|
178
|
+
ack = lifecycle_events.wait_for_canonical_stop(
|
|
179
|
+
event_id=str(event_id or ""),
|
|
180
|
+
timeout_ms=int(timeout_ms or 0),
|
|
181
|
+
poll_ms=int(poll_ms or 500),
|
|
182
|
+
)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
return json.dumps({
|
|
185
|
+
"status": "retryable_error",
|
|
186
|
+
"reason": f"{type(exc).__name__}: {exc}",
|
|
187
|
+
"handler_threw": True,
|
|
188
|
+
}, ensure_ascii=False)
|
|
189
|
+
return json.dumps(ack, ensure_ascii=False)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def handle_nexo_lifecycle_stop_nexo_session(
|
|
193
|
+
sid: str,
|
|
194
|
+
) -> str:
|
|
195
|
+
"""Best-effort explicit stop of a NEXO SID for Desktop lifecycle cleanup."""
|
|
196
|
+
try:
|
|
197
|
+
from tools_sessions import handle_stop
|
|
198
|
+
|
|
199
|
+
message = handle_stop(str(sid or ""))
|
|
200
|
+
return json.dumps({
|
|
201
|
+
"status": "ok",
|
|
202
|
+
"sid": str(sid or ""),
|
|
203
|
+
"message": message,
|
|
204
|
+
}, ensure_ascii=False)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
return json.dumps({
|
|
207
|
+
"status": "retryable_error",
|
|
208
|
+
"sid": str(sid or ""),
|
|
209
|
+
"reason": f"{type(exc).__name__}: {exc}",
|
|
210
|
+
}, ensure_ascii=False)
|
|
211
|
+
|
|
212
|
+
|
|
171
213
|
TOOLS = [
|
|
172
214
|
(
|
|
173
215
|
handle_nexo_lifecycle_event,
|
|
@@ -189,4 +231,14 @@ TOOLS = [
|
|
|
189
231
|
"nexo_lifecycle_wait_for_diary",
|
|
190
232
|
"Wait for concrete session_diary evidence for a canonical lifecycle event before Desktop stops the session.",
|
|
191
233
|
),
|
|
234
|
+
(
|
|
235
|
+
handle_nexo_lifecycle_wait_for_stop,
|
|
236
|
+
"nexo_lifecycle_wait_for_stop",
|
|
237
|
+
"Wait until the linked NEXO session is no longer active for a canonical lifecycle event.",
|
|
238
|
+
),
|
|
239
|
+
(
|
|
240
|
+
handle_nexo_lifecycle_stop_nexo_session,
|
|
241
|
+
"nexo_lifecycle_stop_nexo_session",
|
|
242
|
+
"Best-effort explicit nexo_stop by SID for Desktop lifecycle cleanup.",
|
|
243
|
+
),
|
|
192
244
|
]
|