nexo-brain 7.9.9 → 7.9.11
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 +73 -4
- package/package.json +1 -1
- package/src/agent_runner.py +2 -0
- package/src/cli.py +46 -0
- package/src/doctor/planes.py +9 -5
- package/src/enforcement_engine.py +6 -0
- package/src/lifecycle_events.py +186 -7
- package/src/lifecycle_prompts.py +11 -1
- package/src/plugins/doctor.py +0 -15
- 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.11",
|
|
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.11` is the current packaged-runtime line. Patch release over `7.9.10`: canonical Desktop lifecycle completion now requires both diary evidence and real stop evidence before Brain marks archive/delete/app-exit done, canonical plans add `wait_for_stop`, and Brain exposes the exact stop-wait primitives Desktop uses to avoid leaving duplicate live NEXO sessions behind a reopened conversation. Coordinated Desktop release: v0.28.12.
|
|
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
|
@@ -252,6 +252,24 @@ function readRuntimeCalibration(nexoHome = NEXO_HOME) {
|
|
|
252
252
|
return { path: null, payload: null };
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
function profilePathCandidates(nexoHome = NEXO_HOME) {
|
|
256
|
+
return [
|
|
257
|
+
path.join(nexoHome, "personal", "brain", "profile.json"),
|
|
258
|
+
path.join(nexoHome, "brain", "profile.json"),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function readRuntimeProfile(nexoHome = NEXO_HOME) {
|
|
263
|
+
for (const filePath of profilePathCandidates(nexoHome)) {
|
|
264
|
+
if (!fs.existsSync(filePath)) continue;
|
|
265
|
+
const payload = readJsonFile(filePath);
|
|
266
|
+
if (payload && typeof payload === "object") {
|
|
267
|
+
return { path: filePath, payload };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { path: null, payload: null };
|
|
271
|
+
}
|
|
272
|
+
|
|
255
273
|
function nonEmptyString(value) {
|
|
256
274
|
return typeof value === "string" && value.trim().length > 0;
|
|
257
275
|
}
|
|
@@ -288,6 +306,55 @@ function hasPartialPlaceholderCalibration(calibration) {
|
|
|
288
306
|
return isPlaceholderUserName(name) || !nonEmptyString(language);
|
|
289
307
|
}
|
|
290
308
|
|
|
309
|
+
function firstMeaningfulString(...values) {
|
|
310
|
+
for (const value of values) {
|
|
311
|
+
if (typeof value !== "string") continue;
|
|
312
|
+
const clean = value.trim();
|
|
313
|
+
if (clean) return clean;
|
|
314
|
+
}
|
|
315
|
+
return "";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeLanguageCode(value) {
|
|
319
|
+
const clean = String(value || "").trim().toLowerCase().replace("_", "-");
|
|
320
|
+
if (!clean) return "";
|
|
321
|
+
return clean.split("-")[0];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function resolveExistingIdentityDefaults(nexoHome = NEXO_HOME) {
|
|
325
|
+
const calibration = readRuntimeCalibration(nexoHome).payload || {};
|
|
326
|
+
const user = calibration.user && typeof calibration.user === "object" ? calibration.user : {};
|
|
327
|
+
const profile = readRuntimeProfile(nexoHome).payload || {};
|
|
328
|
+
const version = readJsonFile(path.join(nexoHome, "version.json")) || {};
|
|
329
|
+
|
|
330
|
+
const userName = firstMeaningfulString(
|
|
331
|
+
user.name,
|
|
332
|
+
calibration.user_name,
|
|
333
|
+
profile.user_name,
|
|
334
|
+
version.user_name,
|
|
335
|
+
);
|
|
336
|
+
const language = normalizeLanguageCode(firstMeaningfulString(
|
|
337
|
+
user.language,
|
|
338
|
+
calibration.language,
|
|
339
|
+
profile.language,
|
|
340
|
+
version.language,
|
|
341
|
+
));
|
|
342
|
+
const operatorName = firstMeaningfulString(
|
|
343
|
+
user.assistant_name,
|
|
344
|
+
calibration.assistant_name,
|
|
345
|
+
calibration.operator_name,
|
|
346
|
+
profile.assistant_name,
|
|
347
|
+
profile.operator_name,
|
|
348
|
+
version.operator_name,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
userName: !isPlaceholderUserName(userName) ? userName : "",
|
|
353
|
+
language,
|
|
354
|
+
operatorName: !isReservedAssistantName(operatorName) ? operatorName : "",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
291
358
|
function ensureOnboardingCompletionMarker(nexoHome = NEXO_HOME) {
|
|
292
359
|
const record = readRuntimeCalibration(nexoHome);
|
|
293
360
|
if (!record.path || !isOnboardingComplete(record.payload)) {
|
|
@@ -2966,9 +3033,11 @@ async function runSetup() {
|
|
|
2966
3033
|
},
|
|
2967
3034
|
};
|
|
2968
3035
|
|
|
3036
|
+
const existingIdentity = resolveExistingIdentityDefaults(NEXO_HOME);
|
|
3037
|
+
|
|
2969
3038
|
// Detect language from input or use default
|
|
2970
|
-
let lang = "en";
|
|
2971
|
-
let t = i18n.en;
|
|
3039
|
+
let lang = existingIdentity.language || "en";
|
|
3040
|
+
let t = i18n[lang] || i18n.en;
|
|
2972
3041
|
if (!useDefaults) {
|
|
2973
3042
|
const langInput = await ask(" What's your preferred language? / ¿En qué idioma prefieres hablar?\n > ");
|
|
2974
3043
|
const langLower = langInput.trim().toLowerCase();
|
|
@@ -3009,7 +3078,7 @@ async function runSetup() {
|
|
|
3009
3078
|
// Step 2: User's name (P2) — v6.0.0 empty input falls through to "Usuario"
|
|
3010
3079
|
// instead of keeping an empty string. The calibration file always ships
|
|
3011
3080
|
// with a concrete user.name so downstream tooling does not need guards.
|
|
3012
|
-
let userName = "Usuario";
|
|
3081
|
+
let userName = existingIdentity.userName || "Usuario";
|
|
3013
3082
|
if (!useDefaults) {
|
|
3014
3083
|
const nameInput = await ask(t.askUserName);
|
|
3015
3084
|
const trimmedName = nameInput.trim();
|
|
@@ -3021,7 +3090,7 @@ async function runSetup() {
|
|
|
3021
3090
|
}
|
|
3022
3091
|
|
|
3023
3092
|
// Step 3: Agent name (P3)
|
|
3024
|
-
let operatorName = DEFAULT_ASSISTANT_NAME;
|
|
3093
|
+
let operatorName = existingIdentity.operatorName || DEFAULT_ASSISTANT_NAME;
|
|
3025
3094
|
if (!useDefaults) {
|
|
3026
3095
|
while (true) {
|
|
3027
3096
|
const name = await ask(t.askAgentName);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.11",
|
|
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/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"):
|
package/src/doctor/planes.py
CHANGED
|
@@ -28,6 +28,7 @@ VALID_DIAGNOSTIC_PLANES = {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
DOCTOR_COMPATIBLE_PLANES = {"runtime_personal", "installation_live", "database_real"}
|
|
31
|
+
DEFAULT_DOCTOR_PLANE = "runtime_personal"
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
def normalize_diagnostic_plane(plane: str = "") -> str:
|
|
@@ -40,15 +41,19 @@ def diagnostic_plane_choices() -> list[str]:
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def diagnostic_plane_preflight(plane: str = "") -> tuple[str, DoctorCheck | None]:
|
|
43
|
-
|
|
44
|
+
raw_plane = str(plane or "").strip()
|
|
45
|
+
if not raw_plane:
|
|
46
|
+
return DEFAULT_DOCTOR_PLANE, None
|
|
47
|
+
|
|
48
|
+
clean_plane = normalize_diagnostic_plane(raw_plane)
|
|
44
49
|
if not clean_plane:
|
|
45
50
|
options = ", ".join(diagnostic_plane_choices())
|
|
46
51
|
return "", DoctorCheck(
|
|
47
|
-
id="orchestrator.
|
|
52
|
+
id="orchestrator.diagnostic_plane_invalid",
|
|
48
53
|
tier="orchestrator",
|
|
49
54
|
status="critical",
|
|
50
55
|
severity="error",
|
|
51
|
-
summary="
|
|
56
|
+
summary=f"Plano diagnóstico desconocido: {raw_plane}",
|
|
52
57
|
evidence=[
|
|
53
58
|
f"planes válidos: {options}",
|
|
54
59
|
"Usa `runtime_personal` para ~/.nexo y hábitos del runtime; `installation_live` para hooks/clientes/instalación; `database_real` para filas y schema reales.",
|
|
@@ -58,8 +63,7 @@ def diagnostic_plane_preflight(plane: str = "") -> tuple[str, DoctorCheck | None
|
|
|
58
63
|
"Si el problema pertenece a producto público o al co-operador, usa el surface correcto en vez de NEXO Doctor.",
|
|
59
64
|
],
|
|
60
65
|
escalation_prompt=(
|
|
61
|
-
"
|
|
62
|
-
"explícitamente si el problema está en producto público, runtime personal, instalación viva, BD real o co-operador."
|
|
66
|
+
"El plano elegido no existe. Repite el diagnóstico con un plano válido para evitar mezclar runtime, instalación, BD real o surfaces ajenas al doctor."
|
|
63
67
|
),
|
|
64
68
|
)
|
|
65
69
|
|
|
@@ -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),
|
package/src/plugins/doctor.py
CHANGED
|
@@ -22,26 +22,11 @@ def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text", p
|
|
|
22
22
|
"""
|
|
23
23
|
from doctor.orchestrator import run_doctor
|
|
24
24
|
from doctor.formatters import format_report
|
|
25
|
-
from doctor.planes import diagnostic_plane_choices
|
|
26
25
|
|
|
27
26
|
if tier not in ("boot", "runtime", "deep", "all"):
|
|
28
27
|
return f"Invalid tier '{tier}'. Use: boot, runtime, deep, all"
|
|
29
28
|
if output not in ("text", "json"):
|
|
30
29
|
return f"Invalid output '{output}'. Use: text, json"
|
|
31
|
-
if not (plane or "").strip():
|
|
32
|
-
valid_planes = diagnostic_plane_choices()
|
|
33
|
-
if output == "json":
|
|
34
|
-
return (
|
|
35
|
-
"{"
|
|
36
|
-
f"\"ok\": false, \"error\": \"Missing required argument: plane\", "
|
|
37
|
-
"\"missing_argument\": \"plane\", "
|
|
38
|
-
f"\"valid_planes\": {valid_planes!r}"
|
|
39
|
-
"}"
|
|
40
|
-
).replace("'", '"')
|
|
41
|
-
return (
|
|
42
|
-
"Missing required argument: plane. "
|
|
43
|
-
f"Use one of: {', '.join(valid_planes)}."
|
|
44
|
-
)
|
|
45
30
|
|
|
46
31
|
report = run_doctor(tier=tier, fix=fix, plane=plane)
|
|
47
32
|
return format_report(report, fmt=output)
|
|
@@ -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
|
]
|