answer42 0.2.30__tar.gz → 0.2.31__tar.gz

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.
Files changed (58) hide show
  1. {answer42-0.2.30 → answer42-0.2.31}/PKG-INFO +1 -1
  2. {answer42-0.2.30 → answer42-0.2.31}/pyproject.toml +1 -1
  3. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/server.py +406 -8
  4. {answer42-0.2.30 → answer42-0.2.31}/.gitignore +0 -0
  5. {answer42-0.2.30 → answer42-0.2.31}/LICENSE +0 -0
  6. {answer42-0.2.30 → answer42-0.2.31}/README.md +0 -0
  7. {answer42-0.2.30 → answer42-0.2.31}/credentials.example.json +0 -0
  8. {answer42-0.2.30 → answer42-0.2.31}/docs/agent-installation.md +0 -0
  9. {answer42-0.2.30 → answer42-0.2.31}/docs/architecture.md +0 -0
  10. {answer42-0.2.30 → answer42-0.2.31}/docs/assets/answer42-logo.png +0 -0
  11. {answer42-0.2.30 → answer42-0.2.31}/docs/assets/platform42-logo.svg +0 -0
  12. {answer42-0.2.30 → answer42-0.2.31}/docs/installation.md +0 -0
  13. {answer42-0.2.30 → answer42-0.2.31}/scripts/build_cf.py +0 -0
  14. {answer42-0.2.30 → answer42-0.2.31}/scripts/build_pages.py +0 -0
  15. {answer42-0.2.30 → answer42-0.2.31}/scripts/e2e_stable.py +0 -0
  16. {answer42-0.2.30 → answer42-0.2.31}/scripts/openclaw_mcp_autoreload.py +0 -0
  17. {answer42-0.2.30 → answer42-0.2.31}/scripts/rag_cli.py +0 -0
  18. {answer42-0.2.30 → answer42-0.2.31}/src/cf/ConfigDumpInfo.xml +0 -0
  19. {answer42-0.2.30 → answer42-0.2.31}/src/cf/Configuration.xml +0 -0
  20. {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
  21. {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form/Module.bsl" +0 -0
  22. {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form.xml" +0 -0
  23. {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260.xml" +0 -0
  24. {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
  25. {answer42-0.2.30 → answer42-0.2.31}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
  26. {answer42-0.2.30 → answer42-0.2.31}/src/cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
  27. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Catalogs//320/237/320/241_/320/222/320/273/320/260/320/264/320/265/320/273/320/265/321/206.xml" +0 -0
  28. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Catalogs//320/237/320/241_/320/241/320/277/321/200/320/260/320/262/320/276/321/207/320/275/320/270/320/2721.xml" +0 -0
  29. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/ConfigDumpInfo.xml +0 -0
  30. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Configuration.xml +0 -0
  31. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
  32. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ManagerModule.bsl" +0 -0
  33. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ObjectModule.bsl" +0 -0
  34. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205/Ext/Template.xml" +0 -0
  35. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205.xml" +0 -0
  36. {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262.xml" +0 -0
  37. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/__init__.py +0 -0
  38. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
  39. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
  40. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/__init__.py +0 -0
  41. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
  42. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
  43. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/bridge.py +0 -0
  44. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/credentials.py +0 -0
  45. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/os_support.py +0 -0
  46. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/platform.py +0 -0
  47. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/protocol.py +0 -0
  48. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/__init__.py +0 -0
  49. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/detect.py +0 -0
  50. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/model.py +0 -0
  51. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/parsers.py +0 -0
  52. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/service.py +0 -0
  53. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/store.py +0 -0
  54. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/recorder.py +0 -0
  55. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/release_helper.py +0 -0
  56. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/runtime.py +0 -0
  57. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/skill_installer.py +0 -0
  58. {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/window_control.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: answer42
3
- Version: 0.2.30
3
+ Version: 0.2.31
4
4
  Summary: Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API
5
5
  Author: Marvin (AI Assistant), 42Clouds, and contributors
6
6
  Author-email: "Kosolapov Stanislav (proDOOMman)" <prodoomman@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "answer42"
3
- version = "0.2.30"
3
+ version = "0.2.31"
4
4
  description = "Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -45,7 +45,14 @@ _RAG_ENABLED = os.getenv("ONEC_MCP_DISABLE_RAG", "").strip().lower() not in {"1"
45
45
  _CREDENTIAL_STORE_ENABLED = os.getenv("ONEC_MCP_DISABLE_CREDENTIAL_STORE", "").strip().lower() not in {"1", "true", "yes", "on"}
46
46
  _DEV_TOOLS_ENABLED = os.getenv("ONEC_MCP_DEV_TOOLS", "").strip().lower() in {"1", "true", "yes", "on"}
47
47
  DEFAULT_SESSION = os.getenv("ONEC_MCP_DEFAULT_SESSION", f"default-{os.getpid()}")
48
+ SESSION_SCOPE_ID = os.getenv("ONEC_MCP_SESSION_SCOPE_ID") or os.getenv("ONEC_MCP_SESSION_SCOPE") or ""
48
49
  STATE_FILE = os_support.runtime_path("ONEC_MCP_SESSION_STATE", "sessions.json")
50
+ SCOPE_DIR: Path | None = None
51
+ SCOPE_LOCK_FILE: Path | None = None
52
+ _SCOPE_LOCK_HANDLE: Any | None = None
53
+ GLOBAL_REGISTRY_FILE = os_support.runtime_path("ONEC_MCP_GLOBAL_SESSION_REGISTRY", "global-sessions.json")
54
+ GLOBAL_REGISTRY_LOCK = os_support.runtime_path("ONEC_MCP_GLOBAL_SESSION_REGISTRY_LOCK", "global-sessions.lock")
55
+ GLOBAL_ORPHAN_TTL_SECONDS = int(os.getenv("ONEC_MCP_ORPHAN_TTL_SECONDS", "1800"))
49
56
  AUTORELOAD_PID_FILE = os_support.runtime_path("ONEC_MCP_AUTORELOAD_PID_FILE", "answer42-autoreload.pid")
50
57
  AUTORELOAD_LOG_FILE = os_support.runtime_path("ONEC_MCP_AUTORELOAD_LOG_FILE", "answer42-autoreload.log")
51
58
  RAG_BINDINGS_FILE = os_support.runtime_path("ONEC_MCP_RAG_BINDINGS", "rag-bindings.json")
@@ -387,6 +394,11 @@ class SessionState:
387
394
  self.client_xvfb_process: subprocess.Popen | None = None
388
395
  self.runtime: runtime.ManagerRuntime | None = None
389
396
  self.test_client_process: subprocess.Popen | None = None
397
+ self.manager_pid: int = 0
398
+ self.ibsrv_pid: int = 0
399
+ self.test_client_pid: int = 0
400
+ self.xvfb_pid: int = 0
401
+ self.client_xvfb_pid: int = 0
390
402
  self.bridge = OneCBridgeServer(
391
403
  host="127.0.0.1",
392
404
  port=ws_port,
@@ -418,6 +430,11 @@ class SessionState:
418
430
  "rag_snapshot": self.rag_snapshot,
419
431
  "rag_enabled": self.rag_enabled,
420
432
  "idle_timeout_seconds": self.idle_timeout_seconds,
433
+ "manager_pid": self.runtime.manager_process.pid if self.runtime and self.runtime.manager_process and self.runtime.manager_process.poll() is None else self.manager_pid,
434
+ "ibsrv_pid": self.runtime.server_process.pid if self.runtime and self.runtime.server_process and self.runtime.server_process.poll() is None else self.ibsrv_pid,
435
+ "test_client_pid": self.test_client_process.pid if self.test_client_process and self.test_client_process.poll() is None else self.test_client_pid,
436
+ "xvfb_pid": self.xvfb_process.pid if self.xvfb_process and self.xvfb_process.poll() is None else self.xvfb_pid,
437
+ "client_xvfb_pid": self.client_xvfb_process.pid if self.client_xvfb_process and self.client_xvfb_process.poll() is None else self.client_xvfb_pid,
421
438
  }
422
439
 
423
440
  @classmethod
@@ -441,6 +458,11 @@ class SessionState:
441
458
  sess.client_display = str(data.get("client_display", ""))
442
459
  sess.rag_enabled = bool(data.get("rag_enabled", True))
443
460
  sess.idle_timeout_seconds = int(data.get("idle_timeout_seconds") or 60 * 60)
461
+ sess.manager_pid = int(data.get("manager_pid") or 0)
462
+ sess.ibsrv_pid = int(data.get("ibsrv_pid") or 0)
463
+ sess.test_client_pid = int(data.get("test_client_pid") or 0)
464
+ sess.xvfb_pid = int(data.get("xvfb_pid") or 0)
465
+ sess.client_xvfb_pid = int(data.get("client_xvfb_pid") or 0)
444
466
  return sess
445
467
 
446
468
  @property
@@ -503,6 +525,211 @@ def _pid_is_running(pid: int) -> bool:
503
525
  return True
504
526
 
505
527
 
528
+ def _sanitize_scope_id(scope_id: str) -> str:
529
+ value = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(scope_id or "").strip())
530
+ value = value.strip(".-")
531
+ if not value:
532
+ raise ValueError("Answer42 session scope id must contain at least one safe character")
533
+ return value[:128]
534
+
535
+
536
+ def _state_file_for_scope(scope_id: str) -> tuple[Path, Path, Path]:
537
+ safe = _sanitize_scope_id(scope_id)
538
+ root = Path(os.getenv("ONEC_MCP_SESSION_SCOPES_DIR", str(os_support.default_data_dir() / "session-scopes")))
539
+ scope_dir = root / safe
540
+ return scope_dir, scope_dir / "sessions.json", scope_dir / "answer42.lock"
541
+
542
+
543
+ def _configure_session_scope(scope_id: str) -> None:
544
+ """Switch Answer42 from legacy global state to scoped lock/reconnect mode."""
545
+ global SESSION_SCOPE_ID, STATE_FILE, SCOPE_DIR, SCOPE_LOCK_FILE
546
+ SESSION_SCOPE_ID = str(scope_id or "").strip()
547
+ if not SESSION_SCOPE_ID:
548
+ SCOPE_DIR = None
549
+ SCOPE_LOCK_FILE = None
550
+ STATE_FILE = os_support.runtime_path("ONEC_MCP_SESSION_STATE", "sessions.json")
551
+ return
552
+ SCOPE_DIR, STATE_FILE, SCOPE_LOCK_FILE = _state_file_for_scope(SESSION_SCOPE_ID)
553
+
554
+
555
+ def _acquire_scope_lock_if_configured() -> bool:
556
+ """Acquire the configured scope lock. Return True when a stale lock existed."""
557
+ global _SCOPE_LOCK_HANDLE
558
+ if not SCOPE_LOCK_FILE:
559
+ return False
560
+ SCOPE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
561
+ stale = SCOPE_LOCK_FILE.exists()
562
+ handle = SCOPE_LOCK_FILE.open("a+", encoding="utf-8")
563
+ if os_support.IS_WINDOWS:
564
+ import msvcrt # type: ignore
565
+
566
+ try:
567
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
568
+ except OSError as exc:
569
+ handle.close()
570
+ raise RuntimeError(f"Answer42 session scope {SESSION_SCOPE_ID!r} is locked by another live process") from exc
571
+ else:
572
+ import fcntl # type: ignore
573
+
574
+ try:
575
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
576
+ except BlockingIOError as exc:
577
+ handle.close()
578
+ raise RuntimeError(f"Answer42 session scope {SESSION_SCOPE_ID!r} is locked by another live process") from exc
579
+ handle.seek(0)
580
+ handle.truncate()
581
+ handle.write(json.dumps({"pid": os.getpid(), "scope_id": SESSION_SCOPE_ID, "started_at": datetime.utcnow().isoformat() + "Z"}))
582
+ handle.flush()
583
+ _SCOPE_LOCK_HANDLE = handle
584
+ return stale
585
+
586
+
587
+ def _release_scope_lock() -> None:
588
+ global _SCOPE_LOCK_HANDLE
589
+ if _SCOPE_LOCK_HANDLE is None:
590
+ return
591
+ handle = _SCOPE_LOCK_HANDLE
592
+ _SCOPE_LOCK_HANDLE = None
593
+ try:
594
+ if os_support.IS_WINDOWS:
595
+ import msvcrt # type: ignore
596
+
597
+ handle.seek(0)
598
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
599
+ else:
600
+ import fcntl # type: ignore
601
+
602
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
603
+ finally:
604
+ handle.close()
605
+
606
+
607
+ def _load_global_registry_unlocked() -> dict[str, Any]:
608
+ if not GLOBAL_REGISTRY_FILE.exists():
609
+ return {"sessions": {}}
610
+ try:
611
+ raw = json.loads(GLOBAL_REGISTRY_FILE.read_text(encoding="utf-8"))
612
+ if isinstance(raw, dict):
613
+ raw.setdefault("sessions", {})
614
+ if isinstance(raw["sessions"], dict):
615
+ return raw
616
+ except Exception as exc:
617
+ logging.warning("Could not read Answer42 global session registry %s: %s", GLOBAL_REGISTRY_FILE, exc)
618
+ return {"sessions": {}}
619
+
620
+
621
+ def _write_global_registry_unlocked(data: dict[str, Any]) -> None:
622
+ GLOBAL_REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
623
+ tmp = GLOBAL_REGISTRY_FILE.with_suffix(GLOBAL_REGISTRY_FILE.suffix + ".tmp")
624
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
625
+ tmp.replace(GLOBAL_REGISTRY_FILE)
626
+
627
+
628
+ def _session_registry_key(sess: SessionState) -> str:
629
+ scope = _sanitize_scope_id(SESSION_SCOPE_ID) if SESSION_SCOPE_ID else "legacy"
630
+ return f"{scope}:{sess.session_id}"
631
+
632
+
633
+ def _session_global_registry_record(sess: SessionState, *, status: str = "active") -> dict[str, Any]:
634
+ pids = {
635
+ "manager_pid": sess.runtime.manager_process.pid if sess.runtime and sess.runtime.manager_process and sess.runtime.manager_process.poll() is None else sess.manager_pid,
636
+ "ibsrv_pid": sess.runtime.server_process.pid if sess.runtime and sess.runtime.server_process and sess.runtime.server_process.poll() is None else sess.ibsrv_pid,
637
+ "test_client_pid": sess.test_client_process.pid if sess.test_client_process and sess.test_client_process.poll() is None else sess.test_client_pid,
638
+ "xvfb_pid": sess.xvfb_process.pid if sess.xvfb_process and sess.xvfb_process.poll() is None else sess.xvfb_pid,
639
+ "client_xvfb_pid": sess.client_xvfb_process.pid if sess.client_xvfb_process and sess.client_xvfb_process.poll() is None else sess.client_xvfb_pid,
640
+ }
641
+ return {
642
+ "scope_id": SESSION_SCOPE_ID,
643
+ "session_id": sess.session_id,
644
+ "owner_pid": os.getpid(),
645
+ "status": status,
646
+ "updated_at": time.time(),
647
+ "base_url": sess.base_url,
648
+ "state_file": str(STATE_FILE),
649
+ "data_dir": str(sess.data_dir),
650
+ **pids,
651
+ }
652
+
653
+
654
+ def _update_global_session_registry(sess: SessionState, *, status: str = "active") -> None:
655
+ with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
656
+ data = _load_global_registry_unlocked()
657
+ data["sessions"][_session_registry_key(sess)] = _session_global_registry_record(sess, status=status)
658
+ _write_global_registry_unlocked(data)
659
+
660
+
661
+ def _remove_global_session_registry(sess: SessionState) -> None:
662
+ with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
663
+ data = _load_global_registry_unlocked()
664
+ data.get("sessions", {}).pop(_session_registry_key(sess), None)
665
+ _write_global_registry_unlocked(data)
666
+
667
+
668
+ def _mark_global_sessions_orphaned_for_owner(owner_pid: int | None = None) -> None:
669
+ owner = int(owner_pid or os.getpid())
670
+ now = time.time()
671
+ with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
672
+ data = _load_global_registry_unlocked()
673
+ changed = False
674
+ for rec in data.get("sessions", {}).values():
675
+ if int(rec.get("owner_pid") or 0) != owner:
676
+ continue
677
+ rec["status"] = "orphaned"
678
+ rec["orphaned_at"] = now
679
+ rec["updated_at"] = now
680
+ changed = True
681
+ if changed:
682
+ _write_global_registry_unlocked(data)
683
+
684
+
685
+ def _registry_process_pids(rec: dict[str, Any]) -> list[int]:
686
+ result: list[int] = []
687
+ for name in ("manager_pid", "ibsrv_pid", "test_client_pid", "xvfb_pid", "client_xvfb_pid"):
688
+ try:
689
+ pid = int(rec.get(name) or 0)
690
+ except Exception:
691
+ pid = 0
692
+ if pid > 0 and pid not in result:
693
+ result.append(pid)
694
+ return result
695
+
696
+
697
+ def _reap_global_orphan_sessions(ttl_seconds: int = GLOBAL_ORPHAN_TTL_SECONDS) -> dict[str, Any]:
698
+ """Kill 1C processes recorded for dead Answer42 owners after the TTL."""
699
+ now = time.time()
700
+ reaped: list[str] = []
701
+ orphaned: list[str] = []
702
+ with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
703
+ data = _load_global_registry_unlocked()
704
+ sessions = data.get("sessions", {})
705
+ for key, rec in list(sessions.items()):
706
+ owner_pid = int(rec.get("owner_pid") or 0)
707
+ owner_alive = _pid_is_running(owner_pid)
708
+ if owner_alive and rec.get("status") == "active":
709
+ continue
710
+ if not owner_alive and rec.get("status") == "active":
711
+ rec["status"] = "orphaned"
712
+ rec["orphaned_at"] = now
713
+ rec["updated_at"] = now
714
+ orphaned.append(key)
715
+ continue
716
+ if rec.get("status") != "orphaned":
717
+ continue
718
+ orphaned_at = float(rec.get("orphaned_at") or rec.get("updated_at") or now)
719
+ if now - orphaned_at < ttl_seconds:
720
+ continue
721
+ for pid in _registry_process_pids(rec):
722
+ _kill_pid_group(pid, f"orphaned Answer42 session {key}")
723
+ sessions.pop(key, None)
724
+ reaped.append(key)
725
+ _write_global_registry_unlocked(data)
726
+ return {"orphaned": orphaned, "reaped": reaped, "ttl_seconds": ttl_seconds}
727
+
728
+
729
+ if SESSION_SCOPE_ID:
730
+ _configure_session_scope(SESSION_SCOPE_ID)
731
+
732
+
506
733
  def _start_autoreload_watcher(dev: str = "", dev_restart_timeout: float = 30.0) -> None:
507
734
  """Start a detached dev autoreload watcher once per host.
508
735
 
@@ -568,6 +795,8 @@ def _persist_sessions() -> None:
568
795
  json.dumps({sid: sess.to_json() for sid, sess in _SESSIONS.items()}, ensure_ascii=False, indent=2),
569
796
  encoding="utf-8",
570
797
  )
798
+ for sess in _SESSIONS.values():
799
+ _update_global_session_registry(sess)
571
800
 
572
801
 
573
802
  def _source_newer_than(target: Path, src_dir: Path) -> bool:
@@ -800,6 +1029,132 @@ def _cleanup_stale_session_processes(sess: SessionState, reason: str) -> None:
800
1029
  )
801
1030
 
802
1031
 
1032
+ def _kill_persisted_session_processes(sess: SessionState, reason: str, *, include_test_client: bool) -> None:
1033
+ logging.info("Cleaning persisted session %s processes: %s", sess.session_id, reason)
1034
+ for pid, label in (
1035
+ (sess.manager_pid, "test-manager"),
1036
+ (sess.ibsrv_pid, "ibsrv"),
1037
+ (sess.xvfb_pid, "xvfb"),
1038
+ (sess.client_xvfb_pid, "client-xvfb"),
1039
+ ):
1040
+ _kill_pid_group(pid, f"{label} for Answer42 session {sess.session_id}")
1041
+ if include_test_client:
1042
+ _kill_pid_group(sess.test_client_pid, f"test-client for Answer42 session {sess.session_id}")
1043
+ _cleanup_stale_session_processes(sess, reason)
1044
+
1045
+
1046
+ async def _reconnect_persisted_session(sess: SessionState) -> bool:
1047
+ """Reconnect a scoped session after MCP process restart.
1048
+
1049
+ If the test client is gone, the session is considered finished. If the
1050
+ client is alive and the manager ibsrv is still reachable, restart only the
1051
+ test-manager process and attach it to the existing test client port.
1052
+ """
1053
+ if not _pid_is_running(sess.test_client_pid):
1054
+ _kill_persisted_session_processes(sess, "test client is not running during scoped reconnect", include_test_client=False)
1055
+ return False
1056
+ if sess.ibsrv_pid and not _pid_is_running(sess.ibsrv_pid):
1057
+ _kill_persisted_session_processes(sess, "manager ibsrv is not running during scoped reconnect", include_test_client=True)
1058
+ return False
1059
+ if sess.ibsrv_port and not runtime.is_port_listening("127.0.0.1", sess.ibsrv_port):
1060
+ _kill_persisted_session_processes(sess, "manager ibsrv port is not listening during scoped reconnect", include_test_client=True)
1061
+ return False
1062
+
1063
+ # Old manager was connected to a dead websocket URL. Kill it before
1064
+ # starting a manager that connects to this fresh MCP process' bridge.
1065
+ _kill_pid_group(sess.manager_pid, f"old test-manager for Answer42 session {sess.session_id}")
1066
+
1067
+ await sess.bridge.start()
1068
+ log_dir = sess.log_dir
1069
+ log_dir.mkdir(parents=True, exist_ok=True)
1070
+ rt = _resolve_runtime(
1071
+ platform_dir=sess.platform_dir,
1072
+ db_path=sess.db_path,
1073
+ ibsrv_address="127.0.0.1",
1074
+ ibsrv_port=sess.ibsrv_port,
1075
+ ibsrv_name="mcp",
1076
+ server_data_dir=sess.server_data_dir,
1077
+ )
1078
+ rt.log_dir = log_dir
1079
+ sess.runtime = rt
1080
+ try:
1081
+ if sess.ibsrv_port:
1082
+ ws_url = runtime.build_thin_client_ws_url("127.0.0.1", sess.ibsrv_port)
1083
+ manager_proc = runtime.start_thin_test_manager(
1084
+ onec_path=sess.onec_thin,
1085
+ ws_url=ws_url,
1086
+ websocket_url=sess.bridge.url,
1087
+ log_dir=log_dir,
1088
+ display=sess.display,
1089
+ )
1090
+ else:
1091
+ manager_proc = runtime.start_thin_test_manager_file_direct(
1092
+ onec_path=sess.onec_thin,
1093
+ db_path=sess.db_path,
1094
+ websocket_url=sess.bridge.url,
1095
+ log_dir=log_dir,
1096
+ display=sess.display,
1097
+ )
1098
+ rt.manager_process = manager_proc
1099
+ sess.manager_pid = manager_proc.pid
1100
+ await _wait_for_bridge_connected(sess, timeout=60.0)
1101
+ params = {
1102
+ "base_url": sess.base_url,
1103
+ "username": sess.username,
1104
+ "password": sess.password,
1105
+ "port": sess.testclient_port,
1106
+ "host": "localhost",
1107
+ "client_pid": sess.test_client_pid,
1108
+ "connection_mode": "reconnect",
1109
+ "connection_value": sess.base_url,
1110
+ "target_connection_mode": "reconnect",
1111
+ "expected_manager_protocol_version": MANAGER_PROTOCOL_VERSION,
1112
+ }
1113
+ await sess.bridge.call("attach_test_client", params)
1114
+ _SESSIONS[sess.session_id] = sess
1115
+ logging.info("Reconnected scoped Answer42 session %s", sess.session_id)
1116
+ return True
1117
+ except Exception as exc:
1118
+ logging.warning("Could not reconnect scoped Answer42 session %s: %s", sess.session_id, exc)
1119
+ try:
1120
+ await sess.bridge.stop()
1121
+ except Exception:
1122
+ pass
1123
+ _kill_persisted_session_processes(sess, "scoped reconnect failed", include_test_client=True)
1124
+ return False
1125
+
1126
+
1127
+ async def _recover_scoped_sessions_if_needed(stale_lock_existed: bool) -> None:
1128
+ if not SESSION_SCOPE_ID:
1129
+ return
1130
+ persisted = _load_persisted_sessions()
1131
+ if not persisted:
1132
+ return
1133
+ if not stale_lock_existed:
1134
+ # Normal fresh start in scoped mode: do not attach to old sessions unless
1135
+ # we know the previous scoped MCP process died while owning the folder.
1136
+ return
1137
+ recovered: dict[str, SessionState] = {}
1138
+ for sid, sess in persisted.items():
1139
+ if await _reconnect_persisted_session(sess):
1140
+ recovered[sid] = sess
1141
+ else:
1142
+ shutil.rmtree(sess.data_dir, ignore_errors=True)
1143
+ _SESSIONS.update(recovered)
1144
+ _persist_sessions()
1145
+
1146
+
1147
+ async def _global_orphan_reaper_loop(interval_seconds: float = 60.0) -> None:
1148
+ while True:
1149
+ try:
1150
+ _reap_global_orphan_sessions()
1151
+ except asyncio.CancelledError:
1152
+ raise
1153
+ except Exception as exc:
1154
+ logging.warning("Answer42 global orphan reaper failed: %s", exc)
1155
+ await asyncio.sleep(interval_seconds)
1156
+
1157
+
803
1158
  async def _cleanup_failed_start_session(sess: SessionState, reason: str) -> None:
804
1159
  logging.info("Cleaning failed start_session %s: %s", sess.session_id, reason)
805
1160
  try:
@@ -826,6 +1181,7 @@ async def _cleanup_failed_start_session(sess: SessionState, reason: str) -> None
826
1181
  if sess.data_dir.exists():
827
1182
  shutil.rmtree(sess.data_dir, ignore_errors=True)
828
1183
  _SESSIONS.pop(sess.session_id, None)
1184
+ _remove_global_session_registry(sess)
829
1185
  _persist_sessions()
830
1186
 
831
1187
 
@@ -1627,9 +1983,10 @@ def sessions_list() -> dict[str, Any]:
1627
1983
  "client_display": sess.client_display,
1628
1984
  "ws_port": sess.ws_port,
1629
1985
  "bridge_connected": bool(bridge_status.get("connected")),
1630
- "manager_alive": bool(rt and rt.manager_process and rt.manager_process.poll() is None),
1631
- "ibsrv_alive": bool(rt and rt.server_process and rt.server_process.poll() is None),
1986
+ "manager_alive": bool((rt and rt.manager_process and rt.manager_process.poll() is None) or _pid_is_running(sess.manager_pid)),
1987
+ "ibsrv_alive": bool((rt and rt.server_process and rt.server_process.poll() is None) or _pid_is_running(sess.ibsrv_pid)),
1632
1988
  "testclient_port": sess.testclient_port,
1989
+ "testclient_alive": bool((sess.test_client_process and sess.test_client_process.poll() is None) or _pid_is_running(sess.test_client_pid)),
1633
1990
  "client_ibsrv_port": sess.client_ibsrv_port,
1634
1991
  "client_ibsrv_alive": bool(client_rt and (
1635
1992
  (client_rt.server_process and client_rt.server_process.poll() is None)
@@ -1682,15 +2039,17 @@ def session_status(*,
1682
2039
  "client_display": sess.client_display,
1683
2040
  "bridge": bridge,
1684
2041
  "manager": None if rt is None else {
1685
- "pid": rt.manager_process.pid if rt.manager_process else None,
1686
- "alive": rt.manager_process.poll() is None if rt.manager_process else False,
1687
- "server_pid": rt.server_process.pid if rt.server_process else None,
1688
- "server_alive": rt.server_process.poll() is None if rt.server_process else False,
2042
+ "pid": rt.manager_process.pid if rt.manager_process else (sess.manager_pid or None),
2043
+ "alive": (rt.manager_process.poll() is None if rt.manager_process else _pid_is_running(sess.manager_pid)),
2044
+ "server_pid": rt.server_process.pid if rt.server_process else (sess.ibsrv_pid or None),
2045
+ "server_alive": (rt.server_process.poll() is None if rt.server_process else _pid_is_running(sess.ibsrv_pid)),
1689
2046
  "port": rt.server.port,
1690
2047
  "log_dir": str(rt.log_dir),
1691
2048
  },
1692
2049
  "test_client": {
1693
2050
  "port": sess.testclient_port,
2051
+ "pid": sess.test_client_process.pid if sess.test_client_process else (sess.test_client_pid or None),
2052
+ "alive": (sess.test_client_process.poll() is None if sess.test_client_process else _pid_is_running(sess.test_client_pid)),
1694
2053
  "base_url": sess.base_url,
1695
2054
  "client_ibsrv_port": sess.client_ibsrv_port,
1696
2055
  "server_alive": client_server_alive,
@@ -1781,11 +2140,13 @@ async def start_session(
1781
2140
  sess.rag_snapshot = bound_snapshot
1782
2141
  if display:
1783
2142
  sess.xvfb_process = runtime.start_xvfb(display)
2143
+ sess.xvfb_pid = sess.xvfb_process.pid
1784
2144
  # Separate display for the test client so screenshots capture only the client window
1785
2145
  client_display = "" if os_support.IS_WINDOWS else runtime.find_free_display(runtime.DEFAULT_DISPLAY_START + display_offset + 1)
1786
2146
  sess.client_display = client_display
1787
2147
  if client_display:
1788
2148
  sess.client_xvfb_process = runtime.start_xvfb(client_display)
2149
+ sess.client_xvfb_pid = sess.client_xvfb_process.pid
1789
2150
  await sess.bridge.start()
1790
2151
  _SESSIONS[sid] = sess
1791
2152
  _persist_sessions()
@@ -1829,6 +2190,7 @@ async def start_session(
1829
2190
  direct_range=_direct_range_from_regport(ibsrv_direct_regport),
1830
2191
  )
1831
2192
  runtime_state.server_process = server_proc
2193
+ sess.ibsrv_pid = server_proc.pid
1832
2194
  ws_url = runtime.build_thin_client_ws_url(ibsrv_address, ibsrv_port)
1833
2195
  manager_proc = runtime.start_thin_test_manager(
1834
2196
  onec_path=sess.onec_thin, ws_url=ws_url,
@@ -1846,6 +2208,7 @@ async def start_session(
1846
2208
  display=display,
1847
2209
  )
1848
2210
  runtime_state.manager_process = manager_proc
2211
+ sess.manager_pid = manager_proc.pid
1849
2212
  await _wait_for_bridge_connected(sess, timeout=60.0)
1850
2213
  except Exception as exc:
1851
2214
  diagnostic_error = _manager_failure_diagnostics(sess, exc)
@@ -2662,6 +3025,7 @@ async def _connect_test_client_impl(
2662
3025
  display=sess.client_display or sess.display,
2663
3026
  log_name=client_stdout_log,
2664
3027
  )
3028
+ sess.test_client_pid = sess.test_client_process.pid
2665
3029
  params = {"base_url": r_base, "username": r_user, "password": r_pass,
2666
3030
  "port": r_port, "host": host, "client_pid": sess.test_client_process.pid,
2667
3031
  "client_log": str(client_out_log), "client_stdout_log": str(sess.client_log_dir / client_stdout_log),
@@ -4637,11 +5001,27 @@ def screenshot(
4637
5001
  return result
4638
5002
 
4639
5003
 
4640
- async def _run() -> None:
5004
+ async def _run(stale_scope_lock_existed: bool = False) -> None:
5005
+ reaper_task: asyncio.Task[Any] | None = None
4641
5006
  try:
5007
+ await _recover_scoped_sessions_if_needed(stale_scope_lock_existed)
5008
+ reaper_task = asyncio.create_task(_global_orphan_reaper_loop())
4642
5009
  await mcp.run_stdio_async()
4643
5010
  finally:
5011
+ if reaper_task is not None:
5012
+ reaper_task.cancel()
5013
+ with suppress(asyncio.CancelledError):
5014
+ await reaper_task
4644
5015
  for sid, sess in list(_SESSIONS.items()):
5016
+ if SESSION_SCOPE_ID:
5017
+ if sess.runtime is not None:
5018
+ runtime.kill_process(sess.runtime.manager_process, "test-manager")
5019
+ sess.manager_pid = 0
5020
+ try:
5021
+ await sess.bridge.stop()
5022
+ except Exception:
5023
+ pass
5024
+ continue
4645
5025
  if sess.runtime is not None:
4646
5026
  runtime.kill_process(sess.runtime.manager_process, "test-manager")
4647
5027
  runtime.kill_process(sess.runtime.server_process, "ibsrv")
@@ -4661,7 +5041,14 @@ async def _run() -> None:
4661
5041
  pass
4662
5042
  runtime.stop_xvfb(sess.client_xvfb_process)
4663
5043
  runtime.stop_xvfb(sess.xvfb_process)
5044
+ if SESSION_SCOPE_ID:
5045
+ _persist_sessions()
5046
+ _mark_global_sessions_orphaned_for_owner()
5047
+ else:
5048
+ for sess in list(_SESSIONS.values()):
5049
+ _remove_global_session_registry(sess)
4664
5050
  _SESSIONS.clear()
5051
+ _release_scope_lock()
4665
5052
 
4666
5053
 
4667
5054
  def _register_dev_tools(enabled: bool) -> None:
@@ -4742,6 +5129,11 @@ def main(argv: list[str] | None = None) -> None:
4742
5129
  default=float(os.getenv("ONEC_MCP_DEV_RESTART_TIMEOUT", "30")),
4743
5130
  help="seconds of no file changes before the OpenClaw MCP autoreload watchdog triggers reload",
4744
5131
  )
5132
+ parser.add_argument(
5133
+ "--session-scope",
5134
+ default=os.getenv("ONEC_MCP_SESSION_SCOPE_ID") or os.getenv("ONEC_MCP_SESSION_SCOPE") or "",
5135
+ help="enable scoped lock/reconnect mode for the given session scope id; without this flag Answer42 uses legacy no-lock global state",
5136
+ )
4745
5137
  args = parser.parse_args(argv)
4746
5138
 
4747
5139
  _RAG_ENABLED = not args.disable_rag
@@ -4754,7 +5146,13 @@ def main(argv: list[str] | None = None) -> None:
4754
5146
  else:
4755
5147
  logging.info("Built-in Answer42 RAG is disabled")
4756
5148
  _start_autoreload_watcher(args.dev, args.dev_restart_timeout)
4757
- asyncio.run(_run())
5149
+ if args.session_scope:
5150
+ _configure_session_scope(args.session_scope)
5151
+ stale = _acquire_scope_lock_if_configured()
5152
+ else:
5153
+ stale = False
5154
+ _reap_global_orphan_sessions()
5155
+ asyncio.run(_run(stale))
4758
5156
 
4759
5157
 
4760
5158
  if __name__ == "__main__":
File without changes
File without changes
File without changes
File without changes
File without changes