From 843fdad0659cc91ccbf13c25a553f8f0591214bb Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 15 Sep 2026 09:42:51 +0800 Subject: [PATCH] fix: keep self-learning alive after CLI exit --- Makefile | 4 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 18 +-- README.zh-CN.md | 2 +- docs/self-evolution.md | 10 +- event_store.py | 33 ++++++ learning_worker.py | 199 ++++++++++++++++++++++++++++++++++ main.py | 75 ++++++++++++- pyproject.toml | 2 +- tests/test_distribution.py | 2 + tests/test_learning_worker.py | 87 +++++++++++++++ 12 files changed, 416 insertions(+), 20 deletions(-) create mode 100644 learning_worker.py create mode 100644 tests/test_learning_worker.py diff --git a/Makefile b/Makefile index c1f20ea..4b6da85 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,7 @@ clean: # Syntax check lint: - $(VENV_PYTHON) -m compileall -q main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py + $(VENV_PYTHON) -m compileall -q main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py @echo "Python syntax OK." @echo "All files pass syntax check." @@ -104,7 +104,7 @@ docker-smoke: # Quick verification check: @echo "Checking Python syntax..." - @$(VENV_PYTHON) -m py_compile main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py + @$(VENV_PYTHON) -m py_compile main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py @echo " Python modules: OK" @echo "Checking git tools..." @$(VENV_PYTHON) -c "from tools import AVAILABLE_TOOLS; git = [k for k in AVAILABLE_TOOLS if k.startswith('git_')]; print(f' {len(git)} git tools, {len(AVAILABLE_TOOLS)} total tools')" diff --git a/README.ja.md b/README.ja.md index 8c90df5..32b028c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -391,7 +391,7 @@ extra をインストールしてください。 ### 仕組み -CLI はアイドル時に 30 秒ごとに最大 4 機能をラウンドロビンで実行します。Web/Gateway は永続化された `learning_cycle` ジョブを使い、チャットターンでは入力依存の好み検出と技術検出も実行します。すべての機能は `/self-learning` で個別に切り替えられ、`GET /api/v2/learning/features` で最新状態を確認できます。入力や証拠がない場合は、変更なしの有界な no-op として記録されます。 +CLI はワークスペースごとに単一の分離学習 worker を起動します。CLI がアクティブな間は worker が待機し、ターミナル終了後に心拍が 60 秒以上静かになると、30 秒ごとに最大 4 機能をラウンドロビンで実行します。Web/Gateway は永続化された `learning_cycle` ジョブを使い、チャットターンでは入力依存の好み検出と技術検出も実行します。すべての機能は `/self-learning` で個別に切り替えられ、設定は SQLite に保存されます。`GET /api/v2/learning/features` で最新状態を確認できます。入力や証拠がない場合は、変更なしの有界な no-op として記録されます。 | # | 機能 | 学習内容 | |---|------|---------| diff --git a/README.ko.md b/README.ko.md index 05e5b12..7aa68f8 100644 --- a/README.ko.md +++ b/README.ko.md @@ -391,7 +391,7 @@ extra를 설치하세요. ### 작동 방식 -CLI는 유휴 상태에서 30초마다 최대 4개 기능을 라운드 로빈으로 실행합니다. Web/Gateway는 영속적인 `learning_cycle` 스케줄러 작업을 사용하고, 채팅 턴에서는 입력에 의존하는 선호도 및 기술 감지도 실행합니다. 모든 기능은 `/self-learning`으로 개별 전환할 수 있으며 `GET /api/v2/learning/features`에서 최신 상태를 확인할 수 있습니다. 입력이나 증거가 없으면 변경 없음인 제한된 no-op으로 기록됩니다. +CLI는 작업공간마다 단일 분리 학습 worker를 시작합니다. CLI가 활성 상태인 동안 worker는 대기하고, 터미널이 종료된 뒤 heartbeat가 60초 이상 조용해지면 30초마다 최대 4개 기능을 라운드 로빈으로 실행합니다. Web/Gateway는 영속적인 `learning_cycle` 스케줄러 작업을 사용하고, 채팅 턴에서는 입력에 의존하는 선호도 및 기술 감지도 실행합니다. 모든 기능은 `/self-learning`으로 개별 전환할 수 있고 설정은 SQLite에 저장되며 `GET /api/v2/learning/features`에서 최신 상태를 확인할 수 있습니다. 입력이나 증거가 없으면 변경 없음인 제한된 no-op으로 기록됩니다. | # | 기능 | 학습 내용 | |---|------|---------| diff --git a/README.md b/README.md index 5e0e794..8e883da 100644 --- a/README.md +++ b/README.md @@ -301,14 +301,15 @@ stores `learning.feature_started`, `learning.feature_completed`, or whether it changed durable or in-memory state; `changed: false` is an honest no-op when the required evidence or input is absent. -The CLI dispatches up to four features every 30 seconds while idle. Web and -Gateway processes use the durable `learning_cycle` scheduler job. Chat turns -also dispatch the input-dependent preference and technology features. The -same dispatcher and feature flags are used by all surfaces. Inspect the -latest status with `GET /api/v2/learning/features` or toggle flags with -`/self-learning`. Dynamic tools remain response-time operations protected by -explicit capability and approval gates, and rollback remains user-directed via -`/forget` or an explicit learning rollback command. +The CLI starts a singleton detached learning worker. It waits while the CLI is +active, then continues dispatching up to four features every 30 seconds after +the terminal exits. Web and Gateway processes use the durable `learning_cycle` +scheduler job. Feature switches from `/self-learning` are persisted in SQLite, +so disabled features stay disabled across restarts. Chat turns also dispatch +the input-dependent preference and technology features. Inspect the latest +status with `GET /api/v2/learning/features`. Dynamic tools remain response-time +operations protected by explicit capability and approval gates, and rollback +remains user-directed via `/forget` or an explicit learning rollback command. ### Installed skill guidance @@ -954,6 +955,7 @@ OpenKyrozen/ ├── providers.py # Multi-LLM abstraction (5 providers + fallback) ├── memory.py # SQLite memory with optional rebuildable Chroma index ├── server.py # FastAPI web server + REST API + chat UI +├── learning_worker.py # Detached durable self-learning worker ├── agent_config.py # Strict agent.yaml loader and capability bound ├── agent.yaml # Validated role/provider/capability configuration ├── pyproject.toml # pip package configuration diff --git a/README.zh-CN.md b/README.zh-CN.md index 74b3a66..d5de6e2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -387,7 +387,7 @@ kyrozen ### 工作原理 -CLI 在空闲时每 30 秒通过共享 dispatcher 以 round-robin 方式最多执行 4 个功能;Web/Gateway 通过持久化的 `learning_cycle` 调度任务运行;每次聊天 turn 还会运行依赖输入的偏好和技术检测。所有功能都可用 `/self-learning` 独立开关,`GET /api/v2/learning/features` 可查看最新状态。项目扫描采用增量方式,后台任务有并发限制,失败会记录为事件而不会静默丢弃;缺少输入或证据时会诚实记录为无变化的有界 no-op。 +CLI 会为每个工作区启动一个独立的单例学习 worker。交互式 CLI 活跃时 worker 会等待;终端退出后,心跳静默至少 60 秒,worker 仍会每 30 秒通过共享 dispatcher 以 round-robin 方式最多执行 4 个功能。Web/Gateway 通过持久化的 `learning_cycle` 调度任务运行;每次聊天 turn 还会运行依赖输入的偏好和技术检测。所有功能都可用 `/self-learning` 独立开关,开关会持久化到 SQLite,`GET /api/v2/learning/features` 可查看最新状态。项目扫描采用增量方式,后台任务有并发限制,失败会记录为事件而不会静默丢弃;缺少输入或证据时会诚实记录为无变化的有界 no-op。 | # | 功能 | 学习内容 | |---|------|---------| diff --git a/docs/self-evolution.md b/docs/self-evolution.md index c510a34..b53ffd9 100644 --- a/docs/self-evolution.md +++ b/docs/self-evolution.md @@ -9,7 +9,7 @@ Historical verification snapshot: `51be33361422e55e1f2f00c33a0e0f8c56132a91` (the post-#54 `main` revision, captured before this #55 documentation-only update). Snapshot date: 2026-09-04. -Current repository test count at this snapshot: **198 unittest cases**. +Current repository test count at this snapshot: **201 unittest cases**. ## Verified surface @@ -62,9 +62,11 @@ API below. explicit corrections, and verified failures may enter review. Provider failures, secret-bearing runs, routine one-step work, and project indexing are excluded from behavioral evolution. -2. The background loop reviews after at least 60 seconds without user - interaction. A reviewer can create at most one bounded `policy` or `skill` - canary for the run, or abstain. +2. The CLI launches one detached learning worker per workspace. The worker + waits until the interactive heartbeat is quiet for at least 60 seconds, + then reviews on its 30-second cycle even after the terminal exits. A + reviewer can create at most one bounded `policy` or `skill` canary for the + run, or abstain. Feature switches are persisted in SQLite. 3. Static validation requires the manifest, Markdown sections, size limits, secret redaction, workspace containment, and declared permissions. Learned artifacts cannot add capabilities or dynamic tools. diff --git a/event_store.py b/event_store.py index 42a0f47..70c2d15 100644 --- a/event_store.py +++ b/event_store.py @@ -165,6 +165,16 @@ def _initialise_once(self) -> None: ); CREATE INDEX IF NOT EXISTS idx_learning_status ON learning_proposals(status, workspace_id, updated_at); + CREATE TABLE IF NOT EXISTS learning_feature_flags ( + name TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT 'local', + workspace_id TEXT NOT NULL DEFAULT 'default', + enabled INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + PRIMARY KEY(name, user_id, workspace_id) + ); + CREATE INDEX IF NOT EXISTS idx_learning_feature_flags_scope + ON learning_feature_flags(user_id, workspace_id, name); CREATE TABLE IF NOT EXISTS scheduled_jobs ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -694,6 +704,29 @@ def add_proposal_evidence(self, proposal_id: str, evidence_id: str) -> int: (self._json(evidence), utc_now(), proposal_id)) return len(evidence) + def list_learning_feature_flags(self, *, user_id: str = "local", + workspace_id: str = "default") -> dict[str, bool]: + """Return persisted self-learning feature switches for one scope.""" + with self.connection() as db: + rows = db.execute( + "SELECT name, enabled FROM learning_feature_flags " + "WHERE user_id=? AND workspace_id=?", + (user_id, workspace_id), + ).fetchall() + return {str(row["name"]): bool(row["enabled"]) for row in rows} + + def set_learning_feature_flag(self, name: str, enabled: bool, *, + user_id: str = "local", + workspace_id: str = "default") -> None: + """Persist one self-learning feature switch transactionally.""" + with self._lock, self.connection() as db: + db.execute( + "INSERT INTO learning_feature_flags(name,user_id,workspace_id,enabled,updated_at) " + "VALUES(?,?,?,?,?) ON CONFLICT(name,user_id,workspace_id) DO UPDATE SET " + "enabled=excluded.enabled,updated_at=excluded.updated_at", + (str(name), user_id, workspace_id, int(bool(enabled)), utc_now()), + ) + def list_proposals(self, *, status: str | None = None, workspace_id: str = "default", limit: int = 100, user_id: str | None = None) -> list[dict[str, Any]]: params: list[Any] = [workspace_id] diff --git a/learning_worker.py b/learning_worker.py new file mode 100644 index 0000000..be839b3 --- /dev/null +++ b/learning_worker.py @@ -0,0 +1,199 @@ +"""Detached, singleton worker for durable self-learning cycles.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from threading import Event + +from workspace_context import source_scope_id + + +WORKER_INTERVAL_SECONDS = 30.0 +ACTIVE_CLI_GRACE_SECONDS = 60.0 + + +def _state_root() -> Path: + path = Path.home() / ".kyrozen" / "v2" + path.mkdir(parents=True, exist_ok=True) + return path + + +def worker_paths(workspace_root: str | os.PathLike[str]) -> tuple[Path, Path]: + """Return the singleton lock and CLI heartbeat paths for a workspace.""" + scope = source_scope_id(workspace_root) + root = _state_root() + return root / f"learning-worker-{scope}.pid", root / f"learning-worker-{scope}.heartbeat" + + +def touch_cli_heartbeat(workspace_root: str | os.PathLike[str]) -> Path: + """Record that an interactive CLI is still active for this workspace.""" + _, heartbeat = worker_paths(workspace_root) + heartbeat.write_text(str(time.time()), encoding="utf-8") + return heartbeat + + +def _pid_is_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + +def worker_is_running(workspace_root: str | os.PathLike[str]) -> bool: + """Check and clean a stale worker pid file.""" + lock, _ = worker_paths(workspace_root) + try: + pid = int(lock.read_text(encoding="utf-8").strip()) + except (FileNotFoundError, ValueError, OSError): + if lock.exists(): + lock.unlink(missing_ok=True) + return False + if _pid_is_alive(pid): + return True + lock.unlink(missing_ok=True) + return False + + +def start_worker(*, workspace_root: str | os.PathLike[str], launch_mode: str) -> bool: + """Start one detached worker, returning False only when spawning fails.""" + workspace_root = str(Path(workspace_root).expanduser().resolve()) + touch_cli_heartbeat(workspace_root) + if worker_is_running(workspace_root): + return True + + env = os.environ.copy() + env.update({ + "KYROZEN_EXECUTION_SURFACE": "worker", + "KYROZEN_WORKSPACE_ROOT": workspace_root, + "KYROZEN_LAUNCH_MODE": launch_mode, + "KYROZEN_LEARNING_WORKER": "1", + }) + module_root = str(Path(__file__).resolve().parent) + env["PYTHONPATH"] = module_root + os.pathsep + env.get("PYTHONPATH", "") + kwargs: dict[str, object] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "close_fds": True, + "env": env, + } + if os.name == "nt": + kwargs["creationflags"] = ( + getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + | getattr(subprocess, "DETACHED_PROCESS", 0) + ) + else: + kwargs["start_new_session"] = True + try: + subprocess.Popen([sys.executable, "-m", "learning_worker"], **kwargs) + except OSError: + return False + return True + + +def _claim_worker_lock(lock: Path) -> bool: + try: + fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + if worker_is_running_from_lock(lock): + return False + try: + fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except (FileExistsError, OSError): + return False + except OSError: + return False + try: + os.write(fd, str(os.getpid()).encode("ascii")) + finally: + os.close(fd) + return True + + +def worker_is_running_from_lock(lock: Path) -> bool: + try: + pid = int(lock.read_text(encoding="utf-8").strip()) + except (FileNotFoundError, ValueError, OSError): + lock.unlink(missing_ok=True) + return False + if _pid_is_alive(pid): + return True + lock.unlink(missing_ok=True) + return False + + +def _parent_cli_is_active(heartbeat: Path) -> bool: + try: + return (time.time() - heartbeat.stat().st_mtime) < ACTIVE_CLI_GRACE_SECONDS + except FileNotFoundError: + return False + + +def worker_main() -> int: + workspace_root = os.environ.get("KYROZEN_WORKSPACE_ROOT", "") or str( + Path.home() / ".kyrozen" / "workspace" + ) + lock, heartbeat = worker_paths(workspace_root) + if not _claim_worker_lock(lock): + return 0 + + stop = Event() + + def request_stop(_signum: int, _frame: object) -> None: + stop.set() + + for signum in (getattr(signal, "SIGINT", None), getattr(signal, "SIGTERM", None)): + if signum is not None: + try: + signal.signal(signum, request_stop) + except (OSError, RuntimeError): + pass + + try: + import main as agent + + agent.configure_launch_context() + agent._prompt_and_init_deepseek(interactive=False) + agent._record_learning_event("learning.worker_started", { + "workspace_root": str(Path(workspace_root).resolve()), + "pid": os.getpid(), + }) + while not stop.wait(WORKER_INTERVAL_SECONDS): + if _parent_cli_is_active(heartbeat): + continue + agent._restore_self_learning_flags() + if not any(agent._SELF_LEARNING_FLAGS.values()): + continue + try: + agent.dispatch_learning_cycle( + surface="worker", trigger="detached", max_features=4, + ) + except Exception as exc: + agent.memory_bank.store.append_event( + "learning.worker_cycle_failed", {"error": str(exc)[:1000]}, + user_id=agent.memory_bank.user_id, + workspace_id=agent.memory_bank.workspace_id, + session_id=agent.memory_bank.session_id, + ) + finally: + try: + lock.unlink(missing_ok=True) + except OSError: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(worker_main()) diff --git a/main.py b/main.py index fd05a4d..68abf0d 100644 --- a/main.py +++ b/main.py @@ -419,6 +419,25 @@ def _confirm_tool_action(action: str, args: str = "") -> bool: _SELF_LEARNING_FLAGS: dict[str, bool] = {name: True for name in _LEARNING_FEATURE_ORDER} +def _restore_self_learning_flags() -> dict[str, bool]: + """Load persisted feature switches without widening the learning scope.""" + store_owner = globals().get("memory_bank") + if store_owner is None: + return {} + try: + persisted = store_owner.store.list_learning_feature_flags( + user_id=store_owner.user_id, workspace_id=store_owner.workspace_id, + ) + except Exception: + return {} + restored: dict[str, bool] = {} + for name in _LEARNING_FEATURE_ORDER: + enabled = bool(persisted.get(name, True)) + _SELF_LEARNING_FLAGS[name] = enabled + restored[name] = enabled + return restored + + tasks = TaskManager() # -------- Regression Testing -------- @@ -2179,6 +2198,7 @@ def _set_launch_context(context: LaunchContext) -> LaunchContext: _launch_context = context _set_workspace_root(str(context.active_root)) _restore_user_preferences() + _restore_self_learning_flags() return context @@ -2392,6 +2412,7 @@ def _system_prompt(tools_list: str, agent_config: dict[str, Any] | None = None) memory_bank = MemoryBank() skill_registry = SkillRegistry(memory_bank.store, workspace_id=memory_bank.workspace_id) learning_engine = LearningEngine(memory_bank, registry=skill_registry) +_restore_self_learning_flags() _agent_profile_mode = "auto" _last_learning_run: dict[str, Any] | None = None _learning_notices: list[str] = [] @@ -4524,6 +4545,7 @@ def _chat_turn_impl(user_input: str, clear_tasks: bool = False, profile: str | N global _last_user_interaction, DEEPSEEK_MODEL, _execution_capability_token global _last_learning_run, _learning_notices _last_user_interaction = time.time() + _touch_detached_learning_heartbeat() feedback = learning_engine.feedback_signal(user_input) if feedback and _last_learning_run: previous = _last_learning_run @@ -5728,6 +5750,42 @@ def _background_learning_loop() -> None: pass +def _ensure_detached_learning_worker() -> bool: + """Keep learning alive outside the interactive CLI process.""" + if os.environ.get("KYROZEN_LEARNING_WORKER") == "1": + return True + context = get_launch_context() + if context is None: + return False + try: + from learning_worker import start_worker + return start_worker( + workspace_root=context.active_root, + launch_mode=context.mode, + ) + except Exception as exc: + try: + memory_bank.store.append_event( + "learning.worker_start_failed", {"error": str(exc)[:1000]}, + user_id=memory_bank.user_id, workspace_id=memory_bank.workspace_id, + session_id=memory_bank.session_id, + ) + except Exception: + pass + return False + + +def _touch_detached_learning_heartbeat() -> None: + context = get_launch_context() + if context is None or os.environ.get("KYROZEN_LEARNING_WORKER") == "1": + return + try: + from learning_worker import touch_cli_heartbeat + touch_cli_heartbeat(context.active_root) + except Exception: + pass + + def _show_self_learning_menu() -> None: """Display an interactive menu to toggle self-learning features.""" flag_names = [ @@ -5751,6 +5809,17 @@ def _show_self_learning_menu() -> None: if 0 <= idx < len(flag_names): key = flag_names[idx][0] _SELF_LEARNING_FLAGS[key] = not _SELF_LEARNING_FLAGS[key] + try: + memory_bank.store.set_learning_feature_flag( + key, _SELF_LEARNING_FLAGS[key], + user_id=memory_bank.user_id, + workspace_id=memory_bank.workspace_id, + ) + _record_learning_event("learning.feature_toggled", { + "feature": key, "enabled": _SELF_LEARNING_FLAGS[key], + }) + except Exception: + pass state = "enabled" if _SELF_LEARNING_FLAGS[key] else "disabled" console.print(f"[{_SUCCESS}]Toggled {flag_names[idx][1]} → {state}.[/{_SUCCESS}]") else: @@ -5919,8 +5988,10 @@ def main() -> None: _load_project_files_into_memory() console.print(f"[{_MUTED}]Project files loaded into memory.[/{_MUTED}]") - # Start background learning daemon - threading.Thread(target=_background_learning_loop, daemon=True).start() + # Hand learning off to a detached process so it survives CLI exit. Keep + # the in-process loop only as a safe fallback when process creation fails. + if not _ensure_detached_learning_worker(): + threading.Thread(target=_background_learning_loop, daemon=True).start() while True: try: diff --git a/pyproject.toml b/pyproject.toml index 04cceee..6d061ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ Repository = "https://github.com/EvanProgramming/OpenKyrozen" Issues = "https://github.com/EvanProgramming/OpenKyrozen/issues" [tool.setuptools] -py-modules = ["main", "memory", "providers", "server", "tools", "event_store", "learning_engine", "learning_benchmark", "migration", "task_engine", "scheduler", "skill_registry", "browser_manager", "instruction_loader", "agent_config", "subagents", "capability_tokens", "tool_registry", "dynamic_tools", "plugin_runtime", "workspace_context"] +py-modules = ["main", "memory", "providers", "server", "tools", "event_store", "learning_engine", "learning_benchmark", "learning_worker", "migration", "task_engine", "scheduler", "skill_registry", "browser_manager", "instruction_loader", "agent_config", "subagents", "capability_tokens", "tool_registry", "dynamic_tools", "plugin_runtime", "workspace_context"] include-package-data = true [tool.setuptools.packages.find] diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 7afb894..4a09e8d 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -25,6 +25,7 @@ def test_pyproject_exposes_both_entry_points_and_launch_module(self): self.assertEqual(project["scripts"]["kyrozen"], "main:main") self.assertEqual(project["scripts"]["kyrozen-web"], "server:main_entry") self.assertIn("workspace_context", document["tool"]["setuptools"]["py-modules"]) + self.assertIn("learning_worker", document["tool"]["setuptools"]["py-modules"]) def test_full_development_setup_includes_browser_and_core_test_path(self): with (ROOT / "pyproject.toml").open("rb") as handle: @@ -36,6 +37,7 @@ def test_full_development_setup_includes_browser_and_core_test_path(self): self.assertIn("pip install -e '.[web]'", makefile) self.assertIn("pip install -e '.[all]'", makefile) self.assertIn("python -m playwright install chromium", makefile) + self.assertIn("learning_worker.py", makefile) self.assertIn("test-core:", makefile) self.assertIn("MAKEFLAGS += --no-print-directory", makefile) diff --git a/tests/test_learning_worker.py b/tests/test_learning_worker.py new file mode 100644 index 0000000..03e18a3 --- /dev/null +++ b/tests/test_learning_worker.py @@ -0,0 +1,87 @@ +import os +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import learning_worker +from event_store import EventStore + + +class LearningWorkerTests(unittest.TestCase): + def test_feature_flags_persist_across_store_restarts(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "state.sqlite3" + first = EventStore(path) + first.set_learning_feature_flag("idle_reflection", False, workspace_id="project") + first.set_learning_feature_flag("memory_importance_scoring", True, workspace_id="project") + + reopened = EventStore(path) + self.assertEqual( + reopened.list_learning_feature_flags(workspace_id="project"), + {"idle_reflection": False, "memory_importance_scoring": True}, + ) + self.assertEqual(reopened.list_learning_feature_flags(workspace_id="other"), {}) + + def test_start_worker_uses_detached_singleton_process_and_heartbeat(self): + with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as project: + with patch.dict(os.environ, {"HOME": home}, clear=False), \ + patch.object(learning_worker, "worker_is_running", return_value=False), \ + patch.object(learning_worker.subprocess, "Popen") as popen: + self.assertTrue(learning_worker.start_worker( + workspace_root=project, launch_mode="project", + )) + _, heartbeat = learning_worker.worker_paths(project) + self.assertTrue(heartbeat.exists()) + + args, kwargs = popen.call_args + self.assertEqual(args[0], [sys.executable, "-m", "learning_worker"]) + self.assertEqual(kwargs["env"]["KYROZEN_WORKSPACE_ROOT"], str(Path(project).resolve())) + self.assertEqual(kwargs["env"]["KYROZEN_LAUNCH_MODE"], "project") + self.assertEqual(kwargs["env"]["KYROZEN_EXECUTION_SURFACE"], "worker") + if os.name == "nt": + self.assertIn("creationflags", kwargs) + else: + self.assertTrue(kwargs["start_new_session"]) + + def test_worker_runs_a_cycle_after_cli_heartbeat_is_stale(self): + class OneCycleEvent: + def __init__(self): + self.waits = 0 + + def wait(self, _seconds): + self.waits += 1 + return self.waits > 1 + + with tempfile.TemporaryDirectory() as directory: + lock = Path(directory) / "worker.pid" + heartbeat = Path(directory) / "worker.heartbeat" + fake_store = SimpleNamespace(append_event=lambda *args, **kwargs: None) + fake_agent = SimpleNamespace( + _SELF_LEARNING_FLAGS={"idle_reflection": True}, + configure_launch_context=lambda: None, + _prompt_and_init_deepseek=lambda **kwargs: False, + _record_learning_event=lambda *args, **kwargs: None, + _restore_self_learning_flags=lambda: None, + dispatch_learning_cycle=lambda **kwargs: fake_agent.dispatches.append(kwargs), + dispatches=[], + memory_bank=SimpleNamespace( + user_id="local", workspace_id="default", session_id=None, store=fake_store, + ), + ) + with patch.object(learning_worker, "worker_paths", return_value=(lock, heartbeat)), \ + patch.object(learning_worker, "_claim_worker_lock", return_value=True), \ + patch.object(learning_worker, "Event", return_value=OneCycleEvent()), \ + patch.dict(sys.modules, {"main": fake_agent}): + self.assertEqual(learning_worker.worker_main(), 0) + + self.assertEqual(fake_agent.dispatches, [{ + "surface": "worker", "trigger": "detached", "max_features": 4, + }]) + self.assertFalse(lock.exists()) + + +if __name__ == "__main__": + unittest.main()